From 4e7ab26b16810b4b065ac2db82bb7dc33584e682 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 18 Feb 2026 21:48:18 -0500 Subject: [PATCH 01/78] feat: v2 refactor base --- .dev/docker-compose.yaml | 16 + .gitignore | 13 +- .gitmodules | 3 + .vscode/launch.json | 44 +- Dockerfile | 56 +- LICENSE | 619 ++++++++ README.md | 11 + alembic.ini | 149 ++ app/__init__.py | 2 +- app/config/__init__.py | 18 +- app/config/admin.py | 30 +- app/config/db.py | 51 +- app/config/log.py | 16 + app/config/user.py | 13 + app/core/auth/__init__.py | 25 - app/core/auth/model.py | 181 --- app/core/auth/permission.py | 74 - app/core/auth/user.py | 15 - app/core/cache/__init__.py | 135 ++ app/core/cache/base.py | 24 + app/core/cache/dummy_cache.py | 22 + app/core/cache/redis_cache.py | 30 + app/core/db/__init__.py | 21 +- app/core/db/base.py | 14 +- app/core/db/models.py | 1 + app/core/db/redis.py | 27 + app/core/db/{connection.py => session.py} | 115 +- app/core/log.py | 90 +- app/core/metadata.py | 130 +- app/core/openapi.py | 55 + app/core/rate_limiter/__init__.py | 12 + app/core/rate_limiter/base.py | 32 + app/core/rate_limiter/memory_rate_limiter.py | 66 + app/core/rate_limiter/redis_rate_limiter.py | 92 ++ app/core/router.py | 136 ++ app/core/security/auth.py | 148 ++ app/core/security/model.py | 119 ++ app/docs/openapi.py | 45 - app/domain/enums/__init__.py | 10 - app/domain/enums/error.py | 12 - app/domain/response/__init__.py | 11 - app/domain/response/default_response.py | 6 - app/domain/response/v1/__init__.py | 6 - app/domain/response/v1/data.py | 10 - app/domain/response/v1/default_response.py | 4 - app/log.py | 5 - app/main.py | 151 +- app/module/api/__init__.py | 9 - app/module/api/exception_handler.py | 68 +- app/module/api/router.py | 32 +- app/module/api/schema.py | 37 + app/module/manage/__init__.py | 5 - app/module/manage/domain/response.py | 41 - app/module/manage/router.py | 367 ++--- app/module/manage/schema.py | 47 + app/module/pool/__init__.py | 5 - app/module/pool/config.py | 26 + app/module/pool/domain/common.py | 6 - app/module/pool/domain/request.py | 14 - app/module/pool/domain/response.py | 55 - app/module/pool/model.py | 153 +- app/module/pool/router.py | 124 +- .../enums/pool.py => module/pool/schema.py} | 7 + app/module/pool/service.py | 90 -- app/{domain => schemas}/constants.py | 70 +- app/schemas/enums/__init__.py | 5 + app/schemas/enums/error.py | 13 + app/schemas/response/__init__.py | 4 + app/schemas/response/default_response.py | 4 + .../v1 => schemas/response}/response.py | 132 +- app/utils/time_utils.py | 13 + buf.gen.yaml | 8 + generated/pyproject.toml | 20 + migration/README | 1 + migration/env.py | 97 ++ migration/script.py.mako | 28 + migration/versions/507737e6b05e_init.py | 51 + pyproject.toml | 73 +- schema | 1 + static/favicon.ico | Bin 0 -> 16958 bytes tests/core/test_metadata.py | 14 + tests/proto/test_proto_codegen.py | 51 + uv.lock | 1243 +++++++++++++---- 83 files changed, 4032 insertions(+), 1747 deletions(-) create mode 100644 .dev/docker-compose.yaml create mode 100644 .gitmodules create mode 100644 LICENSE create mode 100644 alembic.ini create mode 100644 app/config/log.py create mode 100644 app/config/user.py delete mode 100644 app/core/auth/__init__.py delete mode 100644 app/core/auth/model.py delete mode 100644 app/core/auth/permission.py delete mode 100644 app/core/auth/user.py create mode 100644 app/core/cache/__init__.py create mode 100644 app/core/cache/base.py create mode 100644 app/core/cache/dummy_cache.py create mode 100644 app/core/cache/redis_cache.py create mode 100644 app/core/db/models.py create mode 100644 app/core/db/redis.py rename app/core/db/{connection.py => session.py} (56%) create mode 100644 app/core/openapi.py create mode 100644 app/core/rate_limiter/__init__.py create mode 100644 app/core/rate_limiter/base.py create mode 100644 app/core/rate_limiter/memory_rate_limiter.py create mode 100644 app/core/rate_limiter/redis_rate_limiter.py create mode 100644 app/core/router.py create mode 100644 app/core/security/auth.py create mode 100644 app/core/security/model.py delete mode 100644 app/docs/openapi.py delete mode 100644 app/domain/enums/__init__.py delete mode 100644 app/domain/enums/error.py delete mode 100644 app/domain/response/__init__.py delete mode 100644 app/domain/response/default_response.py delete mode 100644 app/domain/response/v1/__init__.py delete mode 100644 app/domain/response/v1/data.py delete mode 100644 app/domain/response/v1/default_response.py delete mode 100644 app/log.py delete mode 100644 app/module/api/__init__.py create mode 100644 app/module/api/schema.py delete mode 100644 app/module/manage/__init__.py delete mode 100644 app/module/manage/domain/response.py create mode 100644 app/module/manage/schema.py delete mode 100644 app/module/pool/__init__.py create mode 100644 app/module/pool/config.py delete mode 100644 app/module/pool/domain/common.py delete mode 100644 app/module/pool/domain/request.py delete mode 100644 app/module/pool/domain/response.py rename app/{domain/enums/pool.py => module/pool/schema.py} (74%) rename app/{domain => schemas}/constants.py (86%) create mode 100644 app/schemas/enums/__init__.py create mode 100644 app/schemas/enums/error.py create mode 100644 app/schemas/response/__init__.py create mode 100644 app/schemas/response/default_response.py rename app/{domain/response/v1 => schemas/response}/response.py (53%) create mode 100644 app/utils/time_utils.py create mode 100644 buf.gen.yaml create mode 100644 generated/pyproject.toml create mode 100644 migration/README create mode 100644 migration/env.py create mode 100644 migration/script.py.mako create mode 100644 migration/versions/507737e6b05e_init.py create mode 160000 schema create mode 100644 static/favicon.ico create mode 100644 tests/core/test_metadata.py create mode 100644 tests/proto/test_proto_codegen.py diff --git a/.dev/docker-compose.yaml b/.dev/docker-compose.yaml new file mode 100644 index 0000000..6be8a25 --- /dev/null +++ b/.dev/docker-compose.yaml @@ -0,0 +1,16 @@ +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: wcs_db + POSTGRES_USER: user + POSTGRES_PASSWORD: password + ports: + - "127.0.0.1:5432:5432" + restart: unless-stopped + + redis: + image: redis:7-alpine + ports: + - "127.0.0.1:6379:6379" + restart: unless-stopped \ No newline at end of file diff --git a/.gitignore b/.gitignore index 665053f..8283bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +cov.xml # Translations *.mo @@ -194,6 +195,14 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode -# Custom +### Custom additions below ### +# local sqlite test databases *.sqlite3 -.python-version # Python version file for uv \ No newline at end of file +# Python version file for uv +.python-version +# Generated files, such as protobuf generated code +generated/* +!generated/wynnsource +!generated/pyproject.toml +generated/wynnsource/* +generated/wynnsource/__init__.py \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..05b9e68 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "schema"] + path = schema + url = git@github.com:WynnSource/schema.git diff --git a/.vscode/launch.json b/.vscode/launch.json index e48bfee..9cbda87 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,23 +1,23 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "App Debug", - "type": "debugpy", - "request": "launch", - "module": "uvicorn", - "args": [ - "app.main:app", - "--reload" - ], - "jinja": true, - "justMyCode": false, - "env": { - "WCS_ADMIN_TOKEN": "local-test-token" - }, - } - ] +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "App Debug", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "app.main:app", + "--reload" + ], + "jinja": false, + "justMyCode": false, + "env": { + "WCS_ADMIN_TOKEN": "wcsadmin" + }, + } + ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7753fa2..1cffdba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,29 @@ -# Use a Python image with uv pre-installed -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -# Install the project into `/app` -WORKDIR /app - -# Enable bytecode compilation -ENV UV_COMPILE_BYTECODE=1 - -# Copy from the cache instead of linking since it's a mounted volume -ENV UV_LINK_MODE=copy - -# Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev - -# Then, add the rest of the project source code and install it -# Installing separately from its dependencies allows optimal layer caching -COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev - -# Place executables in the environment at the front of the path -ENV PATH="/app/.venv/bin:$PATH" - -# Run the FastAPI application by default +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install the project into `/app` +WORKDIR /app + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +# Install the project's dependencies using the lockfile and settings +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev + +# Then, add the rest of the project source code and install it +# Installing separately from its dependencies allows optimal layer caching +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +# Run the FastAPI application by default CMD fastapi run \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4aa842 --- /dev/null +++ b/LICENSE @@ -0,0 +1,619 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md index e69de29..36ed6fe 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,11 @@ +## License + +`Copyright (C) <2026> ` + +This project is licensed under the GNU Affero General Public License v3.0 (AGPL-v3) with the following [**Exception**](#exception-for-generated-client-code). + +### Exception for Generated Client Code +As a special exception to the AGPL-v3, the copyright holders of this library give you permission to generate, use, distribute, and license the client libraries (SDKs) generated from this project's API specifications (e.g., OpenAPI/Swagger documents, Protocol Buffers, GraphQL schemas) under any license of your choice, including proprietary licenses. This exception does not apply to the backend logic itself. + +### Reason +We've considered the implications of the AGPL-v3 and have decided to apply it to the backend logic of this project to ensure that any modifications to the server-side code are shared with the community. However, we recognize that client libraries generated from our API specifications may be used in a wide variety of applications, including minecraft mods, which may not be compatible with the AGPL-v3. By granting this exception, we aim to encourage the use of our API and allow developers to create client libraries without worrying about licensing issues, while still ensuring that contributions to the server-side code are shared with the community. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..18e85fd --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migration + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = use_env_variable + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py index 1a08626..6cbe860 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -# blank __init__.py file to mark the directory as a package for discoverability +# blank __init__.py file to mark the directory as a package for discoverability diff --git a/app/config/__init__.py b/app/config/__init__.py index c4c8424..6e4043c 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -1,7 +1,11 @@ -from app.config.admin import ADMIN_CONFIG as ADMIN_CONFIG -from app.config.db import DB_CONFIG as DB_CONFIG - -__all__ = [ - "ADMIN_CONFIG", - "DB_CONFIG", -] +from .admin import ADMIN_CONFIG as ADMIN_CONFIG +from .db import DB_CONFIG as DB_CONFIG +from .log import LOG_CONFIG as LOG_CONFIG +from .user import USER_CONFIG as USER_CONFIG + +__all__ = [ + "ADMIN_CONFIG", + "DB_CONFIG", + "LOG_CONFIG", + "USER_CONFIG", +] diff --git a/app/config/admin.py b/app/config/admin.py index 3440378..c17bd69 100644 --- a/app/config/admin.py +++ b/app/config/admin.py @@ -1,13 +1,17 @@ -from pydantic import Field -from pydantic_settings import BaseSettings - - -class AdminConfig(BaseSettings): - """ - Configuration for the admin interface. - """ - - token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None) - - -ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable +from pydantic import Field +from pydantic_settings import BaseSettings + + +class AdminConfig(BaseSettings): + """ + Configuration for the admin interface. + """ + + token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None) + + +ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable + +__all__ = [ + "ADMIN_CONFIG", +] diff --git a/app/config/db.py b/app/config/db.py index c8f3832..76b38d2 100644 --- a/app/config/db.py +++ b/app/config/db.py @@ -1,22 +1,29 @@ -from typing import Annotated, Optional - -from pydantic import Field, PostgresDsn -from pydantic_settings import BaseSettings - - -class DbConfig(BaseSettings): - postgres_dsn: Annotated[ - Optional[PostgresDsn], - Field( - description="PostgreSQL connection string (leaving empty will use builtin SQLite database)", - alias="WCS_DB_POSTGRES_DSN", - ), - ] = None - sqlite_dsn: str = Field( - default="sqlite+aiosqlite:///./wcs.sqlite3", - description="SQLite connection string (Do not use this in production, only for testing purposes)", - alias="WCS_DB_SQLITE_DSN", - ) - - -DB_CONFIG = DbConfig() +from typing import Annotated + +from pydantic import Field, PostgresDsn, RedisDsn +from pydantic_settings import BaseSettings + + +class DbConfig(BaseSettings): + postgres_dsn: Annotated[ + PostgresDsn, + Field( + description="PostgreSQL connection string (leaving empty will use builtin SQLite database)", + alias="WCS_DB_POSTGRES_DSN", + ), + ] = PostgresDsn("postgresql+asyncpg://user:password@localhost:5432/wcs_db") + + redis_dsn: Annotated[ + RedisDsn | None, + Field( + description="Redis connection string (leaving empty will disable Redis caching)", + alias="WCS_DB_REDIS_DSN", + ), + ] = RedisDsn("redis://localhost:6379/0") + + +DB_CONFIG = DbConfig() + +__all__ = [ + "DB_CONFIG", +] diff --git a/app/config/log.py b/app/config/log.py new file mode 100644 index 0000000..bd2e58e --- /dev/null +++ b/app/config/log.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings + + +class LoggerConfig(BaseSettings): + """ + Logger configuration. + """ + + level: str = "INFO" + format: str = "{time:YY-MM-DD HH:mm:ss} [{level: <8}] {name}:{line} | {message}" + + +LOG_CONFIG = LoggerConfig() +__all__ = [ + "LOG_CONFIG", +] diff --git a/app/config/user.py b/app/config/user.py new file mode 100644 index 0000000..987077c --- /dev/null +++ b/app/config/user.py @@ -0,0 +1,13 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class UserConfig(BaseSettings): + max_ip_records: int = Field(default=10, description="Maximum number of common IPs to store for each user") + + +USER_CONFIG = UserConfig() + +__all__ = [ + "USER_CONFIG", +] diff --git a/app/core/auth/__init__.py b/app/core/auth/__init__.py deleted file mode 100644 index e8f4d4c..0000000 --- a/app/core/auth/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from .model import ( - Permission, - Token, - add_permission_for_token, - add_token, - get_token, - list_tokens, - remove_permission_for_token, - remove_token, -) -from .permission import require_permission -from .user import get_user - -__all__ = [ - "require_permission", - "get_user", - "Token", - "Permission", - "add_token", - "list_tokens", - "remove_token", - "get_token", - "add_permission_for_token", - "remove_permission_for_token", -] diff --git a/app/core/auth/model.py b/app/core/auth/model.py deleted file mode 100644 index 10dc968..0000000 --- a/app/core/auth/model.py +++ /dev/null @@ -1,181 +0,0 @@ -from typing import Sequence - -from app.core.db import Base -from sqlalchemy import Column, ForeignKey, Table, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from sqlalchemy.orm import ( - Mapped, - joinedload, - mapped_column, - relationship, -) - -token_permission_association = Table( - "token_permission_association", - Base.metadata, - Column("token_id", ForeignKey("tokens.id"), primary_key=True), - Column("permission_id", ForeignKey("permissions.id"), primary_key=True), -) - - -class Token(Base): - __tablename__ = "tokens" - id: Mapped[int] = mapped_column(primary_key=True) - token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True) - permissions: Mapped[list["Permission"]] = relationship( - "Permission", secondary=token_permission_association, back_populates="tokens", lazy="joined" - ) - - -class Permission(Base): - __tablename__ = "permissions" - id: Mapped[int] = mapped_column(primary_key=True) - permission_id: Mapped[str] = mapped_column(nullable=False, unique=True, index=True) - description: Mapped[str] = mapped_column(nullable=True) - tokens: Mapped[list["Token"]] = relationship( - "Token", secondary=token_permission_association, back_populates="permissions", lazy="joined" - ) - - -async def list_tokens( - async_session: async_sessionmaker[AsyncSession], -) -> Sequence[Token]: - stmt = select(Token).options(joinedload(Token.permissions)) - async with async_session() as session: - tokens = (await session.execute(stmt)).unique().scalars().all() - return tokens - - -async def add_token( - async_session: async_sessionmaker[AsyncSession], - tokens_str: str | list[str], - permissions_str: str | list[str] = [], -) -> None: - async with async_session() as session: - async with session.begin(): - if isinstance(tokens_str, str): - tokens_str = [tokens_str] - if isinstance(permissions_str, str): - permissions_str = [permissions_str] if permissions_str else [] - - tokens = [ - Token( - token=token_str, - permissions=[await get_or_create_permission(async_session, perm) for perm in permissions_str], - ) - for token_str in tokens_str - ] - - session.add_all(tokens) - - -async def remove_token( - async_session: async_sessionmaker[AsyncSession], - token_str: str | list[str], -) -> None: - stmt = select(Token).where(Token.token == token_str) - async with async_session() as session: - if isinstance(token_str, list): - stmt = select(Token).where(Token.token.in_(token_str)) - else: - stmt = select(Token).where(Token.token == token_str) - - tokens = (await session.execute(stmt)).unique().scalars().all() - - if not tokens: - raise ValueError(f"Token {token_str} not found") - - await session.delete(tokens) - await session.commit() - - -async def get_token( - async_session: async_sessionmaker[AsyncSession], - token_str: str, -) -> Token | None: - stmt = select(Token).where(Token.token == token_str).options(joinedload(Token.permissions)) - async with async_session() as session: - token = (await session.execute(stmt)).unique().scalar_one_or_none() - return token - - -async def add_permission_for_token( - async_session: async_sessionmaker[AsyncSession], - token_str: str, - requested_permissions_str: str | list[str], -) -> Token: - stmt = select(Token).where(Token.token == token_str) - async with async_session() as session: - token = (await session.execute(stmt)).unique().scalar_one_or_none() - - if token is None: - raise ValueError(f"Token {token_str} not found") - - if isinstance(requested_permissions_str, str): - requested_permissions_str = [requested_permissions_str] - - for requested_perm_str in requested_permissions_str: - if any(requested_perm_str == user_perm.permission_id for user_perm in token.permissions): - continue # Permission already exists - - permission = await get_or_create_permission(async_session, requested_perm_str) - token.permissions.append(permission) - - await session.commit() - - return token - - -async def remove_permission_for_token( - async_session: async_sessionmaker[AsyncSession], - token_str: str, - permissions_str: str | list[str], -) -> Token: - stmt = select(Token).where(Token.token == token_str) - async with async_session() as session: - token = (await session.execute(stmt)).unique().scalar_one_or_none() - - if token is None: - raise ValueError(f"Token {token_str} not found") - - if isinstance(permissions_str, str): - permissions_str = [permissions_str] - - for token_perm in token.permissions: - if token_perm.permission_id in permissions_str: - token.permissions.remove(token_perm) - - await session.commit() - return token - - -async def list_permissions( - async_session: async_sessionmaker[AsyncSession], -) -> Sequence[Permission]: - stmt = select(Permission).options(joinedload(Permission.tokens)) - async with async_session() as session: - permissions = (await session.execute(stmt)).unique().scalars().all() - return permissions - - -async def get_permission( - async_session: async_sessionmaker[AsyncSession], - permission_id: str, -) -> Permission | None: - stmt = select(Permission).where(Permission.permission_id == permission_id) - async with async_session() as session: - permission = (await session.execute(stmt)).unique().scalar_one_or_none() - return permission - - -async def get_or_create_permission( - async_session: async_sessionmaker[AsyncSession], - permission_id: str, -) -> Permission: - permission = await get_permission(async_session, permission_id) - if permission is None: - permission = Permission(permission_id=permission_id) - async with async_session() as session: - session.add(permission) - await session.commit() - return permission diff --git a/app/core/auth/permission.py b/app/core/auth/permission.py deleted file mode 100644 index eebd03f..0000000 --- a/app/core/auth/permission.py +++ /dev/null @@ -1,74 +0,0 @@ -import re -from functools import lru_cache -from typing import Annotated - -from app.config import ADMIN_CONFIG -from app.core.db import get_session -from app.core.metadata import EndpointMetadata -from app.log import LOGGER -from fastapi import Depends, Header, HTTPException, Request - -from .model import Permission, Token, get_token - - -@lru_cache(maxsize=256) -def _compile_pattern(pattern: str) -> re.Pattern: - """ - Compile user permission pattern (with optional *) into a regex pattern. - """ - escaped = re.escape(pattern).replace(r"\*", ".*") - return re.compile(f"^{escaped}$") - - -def has_permission(required: str | set[str] | None, user: set[str]) -> bool: - """ - Check if the user's permissions satisfy the required permissions. - Only user permissions can contain wildcards (*). - """ - if required is None: - return True - - if isinstance(required, str): - required = {required} - - for req in required: - # Check for exact match or wildcard match in user permissions - if not any( - _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req for user_perm in user - ): - return False - return True - - -async def require_permission( - request: Request, token: Annotated[str, Header(alias="X-API-KEY")], session=Depends(get_session) -) -> Token: - if not token: - LOGGER.error("Missing API Token in request headers") - raise HTTPException(status_code=401, detail="Missing API Token") - - if token == ADMIN_CONFIG.token: - LOGGER.debug("Admin token used, skipping permission check") - return Token(token="redacted", permissions=[Permission(permission_id="*")]) - - # Fetch the required permissions from the endpoint metadata - endpoint = request.scope.get("endpoint") - metadata = getattr(endpoint, "__metadata__", None) - if not isinstance(metadata, EndpointMetadata): - LOGGER.error(f"Endpoint {endpoint} does not have metadata or is not an EndpointMetadata instance") - raise HTTPException(status_code=500, detail="Internal Server Error: Missing endpoint metadata") - required_perms = metadata.permission - - # Fetch the user permissions from database - user_token = await get_token(session, token) - if not user_token: - LOGGER.warning(f"Invalid API Token: {token}") - raise HTTPException(status_code=401, detail="Invalid API Token") - user_perms = {perm.permission_id for perm in user_token.permissions} - - if not has_permission(required_perms, user_perms): - LOGGER.warning(f"User {user_token.token} does not have required permissions: {required_perms}") - LOGGER.debug(f"User {user_token.token} permissions: {user_perms}") - raise HTTPException(status_code=403, detail="Insufficient permissions") - - return user_token diff --git a/app/core/auth/user.py b/app/core/auth/user.py deleted file mode 100644 index 5d7c5de..0000000 --- a/app/core/auth/user.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Annotated - -from app.config import ADMIN_CONFIG -from app.core.db import get_session -from app.log import LOGGER -from fastapi import Depends, Header - -from .model import Permission, Token, get_token - - -async def get_user(token: Annotated[str, Header(alias="X-API-KEY")], session=Depends(get_session)) -> Token | None: - if token == ADMIN_CONFIG.token: - LOGGER.debug("Admin token used, skipping permission check") - return Token(token="redacted", permissions=[Permission(permission_id="*")]) - return await get_token(session, token) diff --git a/app/core/cache/__init__.py b/app/core/cache/__init__.py new file mode 100644 index 0000000..9798512 --- /dev/null +++ b/app/core/cache/__init__.py @@ -0,0 +1,135 @@ +import hashlib +import typing +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any, overload + +import orjson as json +from fastapi import Request, Response +from pydantic import BaseModel, TypeAdapter + +from app.config import DB_CONFIG +from app.core.log import LOGGER +from app.schemas.constants import INJECTED_NAMESPACE + +from .base import Cache +from .dummy_cache import DummyCache +from .redis_cache import RedisCache + +_cache: Cache = DummyCache() if DB_CONFIG.redis_dsn is None else RedisCache() + + +def _build_cache_key(request: Request) -> str: + key_parts = f"{request.method}:{request.url.path}:{hashlib.md5(str(request.query_params).encode()).hexdigest()}" + return f"cache:{key_parts}" + + +def _serialize(value: Any, model: type | None = None) -> str: + if model is not None and issubclass(model, BaseModel): + adapter = TypeAdapter(model) + return adapter.dump_json(value).decode() + if isinstance(value, BaseModel): + return value.model_dump_json() + return json.dumps(value).decode() + + +def _deserialize[R](raw: str, model: type[R]) -> R: + if issubclass(model, BaseModel): + return model.model_validate_json(raw) + return json.loads(raw) + + +def _extract_from_args[T](args: tuple[object, ...], kwargs: dict[str, object], name: str, cls: type[T]) -> T | None: + """Extract a value of the given type from function args/kwargs.""" + value = kwargs.get(name) + if isinstance(value, cls): + return value + return next((arg for arg in args if isinstance(arg, cls)), None) + + +@overload +def cached[**P, R](func: Callable[P, Awaitable[R]], /) -> Callable[P, Awaitable[R]]: ... +@overload +def cached[**P, R](*, expire: int = 60) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: ... + + +def cached[**P, R]( + func: Callable[P, Awaitable[R]] | None = None, + /, + *, + expire: int = 60, +) -> Callable[P, Awaitable[R]] | Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: + """ + Cache decorator for async FastAPI endpoint handlers. + + Supports two usage patterns:: + + @cached + async def handler(...): ... + + @cached(expire=120) + async def handler(...): ... + + The decorator extracts ``Request`` / ``Response`` from function arguments + to build cache keys and set ``X-Cache`` headers. If no ``Request`` is + found in the arguments, caching is skipped and the function is called + directly. + + :param expire: Cache expiration time in seconds (default: 60) + """ + + def decorator(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + return_type: type[R] | None = typing.get_type_hints(fn).get("return", None) + + @wraps(fn) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + request = _extract_from_args(args, kwargs, "request", Request) or _extract_from_args( + args, kwargs, f"{INJECTED_NAMESPACE}request", Request + ) + response = _extract_from_args(args, kwargs, "response", Response) or _extract_from_args( + args, kwargs, f"{INJECTED_NAMESPACE}response", Response + ) + + if request is None: + LOGGER.warning(f"No Request found in arguments of {fn.__name__}, skipping cache") + return await fn(*args, **kwargs) + + cache_key = _build_cache_key(request) + + raw = await _cache.get(cache_key) + if raw is not None: + if response: + response.headers["X-Cache"] = "HIT" + if return_type is not None: + return _deserialize(raw, return_type) + return json.loads(raw) # type: ignore[return-value] + + if response: + response.headers["X-Cache"] = "MISS" + + try: + result = await fn(*args, **kwargs) + except Exception: + # If the function raises an exception, don't cache it + raise + + try: + serialized = _serialize(result, model=return_type) + await _cache.set(cache_key, serialized, expire=expire) + except (TypeError, ValueError): + LOGGER.warning(f"Failed to serialize cache value for key {cache_key}, skipping cache") + + return result + + return wrapper + + if func is not None: + return decorator(func) + + return decorator + + +__all__ = [ + "Cache", + "cached", +] diff --git a/app/core/cache/base.py b/app/core/cache/base.py new file mode 100644 index 0000000..6134df2 --- /dev/null +++ b/app/core/cache/base.py @@ -0,0 +1,24 @@ +import abc + + +class Cache(abc.ABC): + """ + Abstract base class for cache implementations. + Cache stores and retrieves raw string values (JSON). + Serialization/deserialization is the caller's responsibility. + """ + + @abc.abstractmethod + async def get(self, key: str) -> str | None: + """Get a raw string value from the cache by key.""" + ... + + @abc.abstractmethod + async def set(self, key: str, value: str, expire: int) -> None: + """Set a raw string value in the cache with expiration time in seconds.""" + ... + + @abc.abstractmethod + async def delete(self, key: str) -> None: + """Delete a value from the cache by key.""" + ... diff --git a/app/core/cache/dummy_cache.py b/app/core/cache/dummy_cache.py new file mode 100644 index 0000000..57ada1c --- /dev/null +++ b/app/core/cache/dummy_cache.py @@ -0,0 +1,22 @@ +from typing import override + +from .base import Cache + + +class DummyCache(Cache): + """ + A no-op cache implementation used when Redis is not configured. + All get operations return None, set/delete operations are silently ignored. + """ + + @override + async def get(self, key: str) -> str | None: + pass + + @override + async def set(self, key: str, value: str, expire: int) -> None: + pass + + @override + async def delete(self, key: str) -> None: + pass diff --git a/app/core/cache/redis_cache.py b/app/core/cache/redis_cache.py new file mode 100644 index 0000000..84a941c --- /dev/null +++ b/app/core/cache/redis_cache.py @@ -0,0 +1,30 @@ +from typing import override + +from redis.asyncio import Redis + +from app.core.db.redis import RedisClient + +from .base import Cache + + +class RedisCache(Cache): + _redis: Redis | None = None + + @property + def redis(self) -> Redis: + if self._redis is None: + self._redis = RedisClient.get_instance() + return self._redis + + @override + async def get(self, key: str) -> str | None: + value: str | None = await self.redis.get(key) # type: ignore[assignment] + return value + + @override + async def set(self, key: str, value: str, expire: int) -> None: + await self.redis.set(key, value, ex=expire) + + @override + async def delete(self, key: str) -> None: + await self.redis.delete(key) diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py index 29a656a..cf5e05f 100644 --- a/app/core/db/__init__.py +++ b/app/core/db/__init__.py @@ -1,9 +1,12 @@ -from .base import Base -from .connection import get_engine, get_session, init_db - -__all__ = [ - "Base", - "get_engine", - "get_session", - "init_db", -] +from .base import Base +from .redis import RedisClient +from .session import close_db, get_engine, get_session, init_db + +__all__ = [ + "Base", + "RedisClient", + "close_db", + "get_engine", + "get_session", + "init_db", +] diff --git a/app/core/db/base.py b/app/core/db/base.py index 201f2ed..8d02b4a 100644 --- a/app/core/db/base.py +++ b/app/core/db/base.py @@ -1,7 +1,7 @@ -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - """ - Base class for all SQLAlchemy models. - """ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """ + Base class for all SQLAlchemy models. + """ diff --git a/app/core/db/models.py b/app/core/db/models.py new file mode 100644 index 0000000..9e51514 --- /dev/null +++ b/app/core/db/models.py @@ -0,0 +1 @@ +import app.core.security.model # noqa: F401 diff --git a/app/core/db/redis.py b/app/core/db/redis.py new file mode 100644 index 0000000..fa8dafc --- /dev/null +++ b/app/core/db/redis.py @@ -0,0 +1,27 @@ +from redis import asyncio as aioredis + +from app.config.db import DB_CONFIG + + +class RedisClient: + _instance: aioredis.Redis | None = None + + @classmethod + async def init(cls): + if DB_CONFIG.redis_dsn is None: + raise RuntimeError("Redis DSN is not configured, cannot create Redis client") + cls._instance = await aioredis.from_url( + DB_CONFIG.redis_dsn.encoded_string(), encoding="utf-8", decode_responses=True + ) + + @classmethod + def get_instance(cls) -> aioredis.Redis: + if cls._instance is None: + raise RuntimeError("Redis client is not initialized. Call RedisClient.init() first.") + return cls._instance + + @classmethod + async def close(cls): + if cls._instance is not None: + await cls._instance.close() + cls._instance = None diff --git a/app/core/db/connection.py b/app/core/db/session.py similarity index 56% rename from app/core/db/connection.py rename to app/core/db/session.py index 27c8702..746755d 100644 --- a/app/core/db/connection.py +++ b/app/core/db/session.py @@ -1,53 +1,62 @@ -from app.config import DB_CONFIG -from app.log import LOGGER -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine - -from .base import Base - -engine: AsyncEngine | None = None -session_maker: async_sessionmaker[AsyncSession] | None = None - - -async def get_dsn() -> str: - """ - Returns the database connection string based on the configuration. - If PostgreSQL DSN is not set, it returns the SQLite DSN. - """ - if DB_CONFIG.postgres_dsn is None: - LOGGER.warning("PostgreSQL connection string is not set, using SQLite for testing purposes.") - return DB_CONFIG.sqlite_dsn - return DB_CONFIG.postgres_dsn.encoded_string() - - -async def init_db() -> AsyncEngine: - global engine, session_maker - dsn = await get_dsn() - engine = create_async_engine(dsn, echo=False) - session_maker = async_sessionmaker(engine, expire_on_commit=False) - LOGGER.debug(f"Database engine initialized with DSN: {dsn}") - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - LOGGER.debug("Database tables created successfully.") - return engine - - -def get_engine() -> AsyncEngine: - """ - Returns the SQLAlchemy engine. Initializes it if not already done. - """ - global engine - if engine is None: - LOGGER.error("Database engine is not initialized.") - raise RuntimeError("Database engine is not initialized.") - return engine - - -def get_session() -> async_sessionmaker[AsyncSession]: - """ - Returns the SQLAlchemy session maker. - """ - global session_maker - if session_maker is None: - LOGGER.error("Database session maker is not initialized.") - raise RuntimeError("Database session maker is not initialized.") - return session_maker +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from app.config import DB_CONFIG +from app.core.log import LOGGER + +engine: AsyncEngine | None = None +session_maker: async_sessionmaker[AsyncSession] + + +async def get_dsn() -> str: + """ + Returns the database connection string based on the configuration. + """ + if DB_CONFIG.postgres_dsn is None: + LOGGER.warning("PostgreSQL DSN not provided, exiting application.") + raise RuntimeError("PostgreSQL DSN is required but not provided.") + return DB_CONFIG.postgres_dsn.encoded_string() + + +async def init_db() -> AsyncEngine: + global engine, session_maker + dsn = await get_dsn() + engine = create_async_engine(dsn, echo=False) + session_maker = async_sessionmaker(engine, expire_on_commit=False) + LOGGER.debug(f"Database engine initialized with DSN: {dsn}") + return engine + + +def get_engine() -> AsyncEngine: + """ + Returns the SQLAlchemy engine. Initializes it if not already done. + """ + global engine + if engine is None: + LOGGER.error("Database engine is not initialized.") + raise RuntimeError("Database engine is not initialized.") + return engine + + +async def close_db(): + """ + Closes the database engine connection. + """ + global engine + if engine is not None: + await engine.dispose() + LOGGER.debug("Database engine connection closed.") + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + """ + Returns the SQLAlchemy session maker. + """ + async with session_maker() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/app/core/log.py b/app/core/log.py index 151414c..0b10a2c 100644 --- a/app/core/log.py +++ b/app/core/log.py @@ -1,37 +1,53 @@ -import inspect -import logging -import sys - -import loguru - -logger = loguru.logger - - -class LoguruHandler(logging.Handler): - """Redirects logging messages to Loguru's logger.""" - - def emit(self, record: logging.LogRecord): - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - frame, depth = inspect.currentframe(), 0 - while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -default_format: str = "{time:YY-MM-DD HH:mm:ss} [{level}] {name}:{line} | {message}" - -logger.remove() -logger_id = logger.add( - sys.stdout, - level=0, - diagnose=False, - format=default_format, -) - -__all__ = ["logger"] +import inspect +import logging +import sys + +import loguru + +from app.config import LOG_CONFIG + +LOGGER = loguru.logger + +LOGGER.remove() +logger_id = LOGGER.add( + sys.stdout, + level=LOG_CONFIG.level, + diagnose=False, + format=LOG_CONFIG.format, +) + + +class LoguruHandler(logging.Handler): + """Redirects logging messages to Loguru's logger.""" + + def emit(self, record: logging.LogRecord): + try: + level = LOGGER.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = inspect.currentframe(), 0 + while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): + frame = frame.f_back + depth += 1 + + LOGGER.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def setup_logging(): + logging_root = logging.getLogger() + logging_root.setLevel(0) + logging_root.handlers = [] + + logging_root.addHandler(LoguruHandler()) + + # Remove handlers from all existing loggers to prevent duplicate logs + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + + +setup_logging() + + +__all__ = ["LOGGER"] diff --git a/app/core/metadata.py b/app/core/metadata.py index 26d63ab..fec7cb6 100644 --- a/app/core/metadata.py +++ b/app/core/metadata.py @@ -1,30 +1,100 @@ -from dataclasses import dataclass - - -@dataclass -class EndpointMetadata: - """ - Metadata for an API endpoint. - """ - - permission: str | set[str] | None = None - - def __init__(self, permission: str | set[str] | None = None): - self.permission = permission - - -def with_metadata(*, permission: str | set[str] | None = None): - """ - Decorator to add metadata to an API endpoint. - """ - - def decorator(func): - if getattr(func, "__metadata__", None) is not None: - raise ValueError("Function already has metadata.") - setattr(func, "__metadata__", EndpointMetadata(permission)) - return func - - return decorator - - -__all__ = ["EndpointMetadata", "with_metadata"] +import dataclasses +from collections.abc import Awaitable, Callable + +from app.core.rate_limiter.base import RateLimitKeyFunc, default_key_func + + +@dataclasses.dataclass +class CacheConfig: + expire: int = 60 + + +@dataclasses.dataclass +class RateLimitMetadata: + limit: int + period: int + key_func: RateLimitKeyFunc + + +@dataclasses.dataclass +class EndpointMetadata: + """ + Metadata for an API endpoint. + """ + + # Indicates whether the metadata has been processed by the router. + processed: bool = False + + permission: str | set[str] | None = None + cache: CacheConfig | None = None + rate_limit: RateLimitMetadata | None = None + + +def cached(expire: int = 60): + """ + Decorator to add cache metadata to an API endpoint. + """ + + def decorator(func: Callable[..., Awaitable]): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace(meta, cache=CacheConfig(expire=expire)) + else: + meta = EndpointMetadata(cache=CacheConfig(expire=expire)) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +def permission(permission: str | set[str] | None = None): + """ + Decorator to add metadata to an API endpoint. + """ + + def decorator(func): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace(meta, permission=permission) + else: + meta = EndpointMetadata(permission=permission) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = default_key_func): + """ + Decorator to add rate limit metadata to an API endpoint. + """ + + def decorator(func): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace( + meta, rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func) + ) + else: + meta = EndpointMetadata(rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func)) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +# metadata = MetadataBuilder() + +__all__ = ["EndpointMetadata", "cached", "permission", "rate_limit"] diff --git a/app/core/openapi.py b/app/core/openapi.py new file mode 100644 index 0000000..28314ff --- /dev/null +++ b/app/core/openapi.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ + + +def custom_openapi(app: FastAPI): + def openapi(): + nonlocal app + + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=__NAME__, + version=__VERSION__, + summary=f"{__NAME__} API Documentation", + description=__DESCRIPTION__, + routes=app.routes, + ) + + openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": ["management", "pool"]}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + return openapi + + +RATE_LIMIT_DOCS = { + 200: { + "headers": { + "X-RateLimit-Limit": {"schema": {"type": "integer"}, "description": "Requests allowed per window"}, + "X-RateLimit-Remaining": {"schema": {"type": "integer"}, "description": "Requests remaining"}, + "X-RateLimit-Reset": {"schema": {"type": "integer"}, "description": "Seconds until reset"}, + }, + }, + 429: { + "description": "Too Many Requests", + "headers": { + "X-RateLimit-Limit": {"schema": {"type": "integer"}, "description": "Requests allowed per window"}, + "X-RateLimit-Remaining": {"schema": {"type": "integer"}, "description": "Requests remaining"}, + "X-RateLimit-Reset": {"schema": {"type": "integer"}, "description": "Seconds until reset"}, + "X-Retry-After": {"schema": {"type": "integer"}, "description": "Seconds until you can retry"}, + }, + }, +} + +CACHE_DOCS = { + 200: { + "headers": { + "X-Cache": {"schema": {"type": "string", "enum": ["HIT", "MISS"]}, "description": "Cache status"}, + } + } +} diff --git a/app/core/rate_limiter/__init__.py b/app/core/rate_limiter/__init__.py new file mode 100644 index 0000000..11f61c7 --- /dev/null +++ b/app/core/rate_limiter/__init__.py @@ -0,0 +1,12 @@ +from app.config.db import DB_CONFIG +from app.core.rate_limiter.base import BaseRateLimiter as BaseRateLimiter + +from .memory_rate_limiter import MemoryRateLimiter +from .redis_rate_limiter import RedisRateLimiter + +# export the only RateLimiter based on config +RateLimiter: type[BaseRateLimiter] = MemoryRateLimiter if DB_CONFIG.redis_dsn is None else RedisRateLimiter + +__all__ = [ + "RateLimiter", +] diff --git a/app/core/rate_limiter/base.py b/app/core/rate_limiter/base.py new file mode 100644 index 0000000..2229bc9 --- /dev/null +++ b/app/core/rate_limiter/base.py @@ -0,0 +1,32 @@ +import abc +from collections.abc import Callable + +from fastapi import Request, Response + +type RateLimitKeyFunc = Callable[[Request], str] + + +def default_key_func(request: Request) -> str: + """ + Default key function that uses the client's IP address for rate limiting. + If none of the above is available, it falls back to a global limit across all clients. + """ + ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "global") + path = request.url.path + method = request.method + return f"rate_limiting:{method}:{path}:{ip}" + + +class BaseRateLimiter(abc.ABC): + limit: int + period: int + key_func: RateLimitKeyFunc + + def __init__(self, limit: int, period: int, key_func: RateLimitKeyFunc = default_key_func): + self.limit = limit + self.period = period + self.key_func = key_func + + @abc.abstractmethod + async def __call__(self, request: Request, response: Response) -> Response: + pass diff --git a/app/core/rate_limiter/memory_rate_limiter.py b/app/core/rate_limiter/memory_rate_limiter.py new file mode 100644 index 0000000..c66bbda --- /dev/null +++ b/app/core/rate_limiter/memory_rate_limiter.py @@ -0,0 +1,66 @@ +import math +import time + +from fastapi import HTTPException, Request, Response +from starlette.status import HTTP_429_TOO_MANY_REQUESTS + +from .base import BaseRateLimiter, RateLimitKeyFunc, default_key_func + + +class MemoryRateLimiter(BaseRateLimiter): + current_window: int + current_counts: dict[str, int] + previous_counts: dict[str, int] + + def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = default_key_func): + super().__init__(times, seconds, key_func) + self.current_window = 0 + self.current_counts = {} + self.previous_counts = {} + + def _rotate(self, window: int): + if window == self.current_window: + return + if window == self.current_window + 1: + self.previous_counts = self.current_counts + else: + self.previous_counts = {} + self.current_counts = {} + self.current_window = window + + async def __call__(self, request: Request, response: Response) -> Response: + key = self.key_func(request) + now = time.time() + window = int(now // self.period) + elapsed_ratio = (now % self.period) / self.period + + self._rotate(window) + + prev = self.previous_counts.get(key, 0) + curr = self.current_counts.get(key, 0) + estimated = prev * (1 - elapsed_ratio) + curr + + reset_after = self.period - (now % self.period) + + response.headers["X-RateLimit-Limit"] = str(self.limit) + response.headers["X-RateLimit-Reset"] = str(math.ceil(reset_after)) + + if estimated >= self.limit: + response.headers["X-RateLimit-Remaining"] = "0" + response.headers["Retry-After"] = str(math.ceil(reset_after)) + raise HTTPException( + HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests", + headers={ + "X-RateLimit-Limit": str(self.limit), + "X-RateLimit-Reset": str(math.ceil(reset_after)), + "X-RateLimit-Remaining": "0", + "Retry-After": str(math.ceil(reset_after)), + }, + ) + + self.current_counts[key] = curr + 1 + remaining = self.limit - math.ceil(prev * (1 - elapsed_ratio) + curr + 1) + response.headers["X-RateLimit-Remaining"] = str(max(0, remaining)) + + return response diff --git a/app/core/rate_limiter/redis_rate_limiter.py b/app/core/rate_limiter/redis_rate_limiter.py new file mode 100644 index 0000000..535c629 --- /dev/null +++ b/app/core/rate_limiter/redis_rate_limiter.py @@ -0,0 +1,92 @@ +import math +import time + +from fastapi import HTTPException, Request, Response +from redis.asyncio.client import Redis +from redis.commands.core import AsyncScript +from starlette.status import HTTP_429_TOO_MANY_REQUESTS + +from app.core.db.redis import RedisClient + +from .base import BaseRateLimiter, RateLimitKeyFunc, default_key_func + +LUA_SLIDING_WINDOW = """ +local key_prefix = KEYS[1] +local limit = tonumber(ARGV[1]) +local window_size = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) + +local current_window = math.floor(now / window_size) +local elapsed_ratio = (now % window_size) / window_size + +local curr_key = key_prefix .. ":" .. current_window +local prev_key = key_prefix .. ":" .. (current_window - 1) + +local prev_count = tonumber(redis.call("GET", prev_key) or "0") +local curr_count = tonumber(redis.call("GET", curr_key) or "0") +local estimated = prev_count * (1 - elapsed_ratio) + curr_count + +if estimated >= limit then + local reset_after = math.ceil(window_size - (now % window_size)) + return {-1, reset_after} +end + +curr_count = redis.call("INCR", curr_key) +if curr_count == 1 then + redis.call("EXPIRE", curr_key, window_size * 2) +end + +estimated = prev_count * (1 - elapsed_ratio) + curr_count +local remaining = math.max(0, math.floor(limit - estimated)) +local reset_after = math.ceil(window_size - (now % window_size)) +return {remaining, reset_after} +""" + + +class RedisRateLimiter(BaseRateLimiter): + _redis: Redis | None = None + _script: AsyncScript | None = None + + @property + def redis(self) -> Redis: + if self._redis is None: + self._redis = RedisClient.get_instance() + return self._redis + + @property + def script(self) -> AsyncScript: + if self._script is None: + self._script = self.redis.register_script(LUA_SLIDING_WINDOW) + return self._script + + def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = default_key_func): + super().__init__(times, seconds, key_func) + + async def __call__(self, request: Request, response: Response) -> Response: + + key = self.key_func(request) + + now = time.time() + result = await self.script(keys=[key], args=[self.limit, self.period, now]) + remaining, reset_after = int(result[0]), int(result[1]) + + response.headers["X-RateLimit-Limit"] = str(self.limit) + response.headers["X-RateLimit-Reset"] = str(reset_after) + + if remaining < 0: + response.headers["X-RateLimit-Remaining"] = "0" + response.headers["Retry-After"] = str(reset_after) + raise HTTPException( + HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests", + headers={ + "X-RateLimit-Limit": str(self.limit), + "X-RateLimit-Reset": str(math.ceil(reset_after)), + "X-RateLimit-Remaining": "0", + "Retry-After": str(math.ceil(reset_after)), + }, + ) + + response.headers["X-RateLimit-Remaining"] = str(remaining) + + return response diff --git a/app/core/router.py b/app/core/router.py new file mode 100644 index 0000000..844d487 --- /dev/null +++ b/app/core/router.py @@ -0,0 +1,136 @@ +import inspect +from collections.abc import Callable, Sequence +from functools import wraps +from typing import Any + +from fastapi import Depends, Request, Response, params +from fastapi.routing import APIRoute + +from app.core.cache import cached +from app.core.metadata import EndpointMetadata +from app.core.openapi import CACHE_DOCS, RATE_LIMIT_DOCS +from app.core.rate_limiter import RateLimiter +from app.core.security.auth import depends_permission +from app.schemas.constants import INJECTED_NAMESPACE +from app.utils.time_utils import format_time + + +class DocedAPIRoute(APIRoute): + def __init__( + self, + path: str, + endpoint: Callable[..., Any], + *, + dependencies: Sequence[params.Depends] | None = None, + responses: dict[int | str, dict[str, Any]] | None = None, + description: str | None = None, + **kwargs, + ): + meta: EndpointMetadata = getattr(endpoint, "__metadata__", EndpointMetadata()) + + if meta.processed: + super().__init__( + path, endpoint, dependencies=dependencies, responses=responses, description=description, **kwargs + ) + return + + meta.processed = True + + responses = responses or {} + + # Inject caching + if meta.cache: + endpoint = cached(expire=meta.cache.expire)(self.inject_sig(endpoint)) + + for status_code, doc in CACHE_DOCS.items(): + if status_code not in responses: + responses[status_code] = {} + + if "headers" not in responses[status_code]: + responses[status_code]["headers"] = {} + responses[status_code]["headers"].update(doc["headers"]) + + if "description" in doc and "description" not in responses[status_code]: + responses[status_code]["description"] = doc["description"] + + description = self.add_description( + f"⌛ This response is cached for `{meta.cache.expire}` seconds.", description, endpoint + ) + + # Add permission info to description and inject permission dependency + if meta.permission: + description = self.add_description( + "🛡️ Required Permission(s): " + + ", ".join( + f"`{p}`" for p in (meta.permission if isinstance(meta.permission, set) else {meta.permission}) + ), + description, + endpoint, + ) + + dependencies = [*list(dependencies or []), Depends(depends_permission(meta.permission))] + + # Add rate limit docs and inject rate limit dependency + if meta.rate_limit: + for status_code, doc in RATE_LIMIT_DOCS.items(): + if status_code not in responses: + responses[status_code] = {} + + if "headers" not in responses[status_code]: + responses[status_code]["headers"] = {} + responses[status_code]["headers"].update(doc["headers"]) + + if "description" in doc and "description" not in responses[status_code]: + responses[status_code]["description"] = doc["description"] + + description = self.add_description( + f"⏱️ Rate Limited: `{meta.rate_limit.limit}` requests per `{format_time(meta.rate_limit.period)}`.", + description, + endpoint, + ) + + dependencies = [ + *list(dependencies or []), + Depends(RateLimiter(meta.rate_limit.limit, meta.rate_limit.period, meta.rate_limit.key_func)), + ] + + super().__init__( + path, endpoint, dependencies=dependencies, responses=responses, description=description, **kwargs + ) + + def add_description(self, text: str, description: str | None, endpoint: Callable[..., Any]) -> str: + description = description or inspect.cleandoc(endpoint.__doc__ or "") + if description: + description += "\n\n" + description += text + return description + + def inject_sig(self, endpoint: Callable[..., Any]) -> Callable[..., Any]: + sig = inspect.signature(endpoint) + has_request = any(p.annotation is Request for p in sig.parameters.values()) + has_response = any(p.annotation is Response for p in sig.parameters.values()) + + @wraps(endpoint) + async def injected_endpoint(*args, **kwargs): + fn_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters} + return await endpoint(*args, **fn_kwargs) + + if not has_request or not has_response: + new_params = list(sig.parameters.values()) + if not has_request: + new_params.append( + inspect.Parameter( + f"{INJECTED_NAMESPACE}request", inspect.Parameter.KEYWORD_ONLY, annotation=Request + ) + ) + if not has_response: + new_params.append( + inspect.Parameter( + f"{INJECTED_NAMESPACE}response", inspect.Parameter.KEYWORD_ONLY, annotation=Response + ) + ) + setattr(injected_endpoint, "__signature__", sig.replace(parameters=new_params)) + return injected_endpoint + + +__all__ = ["DocedAPIRoute"] diff --git a/app/core/security/auth.py b/app/core/security/auth.py new file mode 100644 index 0000000..a75d234 --- /dev/null +++ b/app/core/security/auth.py @@ -0,0 +1,148 @@ +import re +from datetime import UTC, datetime +from functools import lru_cache +from hashlib import sha256 +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, Request, Security +from fastapi.security import APIKeyHeader +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.status import HTTP_401_UNAUTHORIZED + +from app.config import ADMIN_CONFIG +from app.core.db import get_session +from app.core.log import LOGGER + +from .model import Token, User, get_user_by_token, update_user_ip + + +@lru_cache(maxsize=256) +def _compile_pattern(pattern: str) -> re.Pattern: + """ + Compile user permission pattern (with optional *) into a regex pattern. + """ + escaped = re.escape(pattern).replace(r"\*", ".*") + return re.compile(f"^{escaped}$") + + +def has_permission(required: str | set[str] | None, user: set[str]) -> bool: + """ + Check if the user's permissions satisfy the required permissions. + Only user permissions can contain wildcards (*). + """ + if required is None: + return True + + if isinstance(required, str): + required = {required} + + for req in required: + # Check for exact match or wildcard match in user permissions + if not any( + _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req for user_perm in user + ): + return False + return True + + +def check_permissions(token: Token, required_permissions: set[str] | None) -> None: + """ + Check if the token's permissions satisfy the required permissions. + Raises HTTPException if permissions are insufficient. + """ + user_permissions = set(token.permissions) + if not has_permission(required_permissions, user_permissions): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Insufficient permissions") + + +async def verify_token(user: User): + token = user.token + if token.expires_at and token.expires_at < datetime.now(UTC): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired") + + +api_key_scheme = APIKeyHeader(name="X-API-KEY", auto_error=False) + + +async def get_user( + request: Request, + x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, + api_key: str = Security(api_key_scheme), + session: AsyncSession = Depends(get_session), +) -> User: + if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None: + # Create a dummy token with all permissions for the admin token + LOGGER.info("Admin token used, granting all permissions") + return User( + token=Token( + token="", + permissions=["*"], + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC), + ), + score=0, + common_ips=[], + ) + else: + api_key = hash_token(api_key) + user = await get_user_by_token(session, api_key) + + assert request.client is not None + + if request.client.host == "127.0.0.1": + LOGGER.warning(f"Token {user.token.token} used from localhost without X-Real-IP header") + elif x_real_ip is None: + LOGGER.warning(f"Token {user.token.token} used without X-Real-IP header") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address") + await update_user_ip(session, user, x_real_ip or request.client.host) + if not user: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token") + return user + + +def depends_permission(permission: str | set[str]): + async def dependency(request: Request, user: User = Depends(get_user), session=Depends(get_session)) -> User: + if not has_permission(permission, set(user.token.permissions)): + LOGGER.warning(f"User {user.token.token} does not have required permissions: {permission}") + LOGGER.debug(f"User {user.token.token} permissions: {user.token.permissions}") + raise HTTPException(status_code=403, detail="Insufficient permissions") + + return user + + return dependency + + +# Used before custom APIRoute is implemented, kept for reference and potential future use +# async def require_permission(request: Request, user: User = Depends(get_user), session=Depends(get_session)) -> User: + +# # Fetch the required permissions from the endpoint metadata +# endpoint = request.scope.get("endpoint") +# metadata = getattr(endpoint, "__metadata__", None) +# if not isinstance(metadata, EndpointMetadata): +# LOGGER.error(f"Endpoint {endpoint} does not have metadata or is not an EndpointMetadata instance") +# raise HTTPException(status_code=500, detail="Internal Server Error: Missing endpoint metadata") +# required_perms = metadata.permission + +# if not has_permission(required_perms, set(user.token.permissions)): +# LOGGER.warning(f"User {user.token.token} does not have required permissions: {required_perms}") +# LOGGER.debug(f"User {user.token.token} permissions: {user.token.permissions}") +# raise HTTPException(status_code=403, detail="Insufficient permissions") + +# return user + + +async def get_token(token_str: str, session=Depends(get_session)) -> Token: + LOGGER.debug(f"Verifying token: {token_str}") + user = await get_user_by_token(session, token_str) + await verify_token(user) + return user.token + + +def hash_token(token_str: str) -> str: + if token_str is None: + return None + return sha256(token_str.encode()).hexdigest() + + +def hash_tokens(token_strs: list[str]) -> list[str]: + return [sha256(t.encode()).hexdigest() for t in token_strs] diff --git a/app/core/security/model.py b/app/core/security/model.py new file mode 100644 index 0000000..bd24a3c --- /dev/null +++ b/app/core/security/model.py @@ -0,0 +1,119 @@ +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import DateTime, String, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import ( + Mapped, + joinedload, + mapped_column, + relationship, +) +from sqlalchemy.schema import ForeignKey +from sqlalchemy.types import ARRAY +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED + +from app.config import USER_CONFIG +from app.core.db import Base +from app.core.log import LOGGER + + +class Token(Base): + __tablename__ = "tokens" + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(default=True) + permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) + user: Mapped["User"] = relationship(back_populates="token", uselist=False, cascade="all, delete-orphan") + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(ForeignKey("tokens.id"), primary_key=True) + score: Mapped[int] = mapped_column(default=0) + # A list of common IPs used by this user, ordered from oldest to newest. + common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) + creation_ip: Mapped[str] = mapped_column(String, nullable=False) + token: Mapped["Token"] = relationship(back_populates="user", single_parent=True) + + +async def get_user_by_token(session: AsyncSession, token_str: str) -> User: + user = await session.execute( + select(User) + .join(Token) + .where(Token.token == token_str, Token.is_active == True) # noqa: E712 + .options(joinedload(User.token)) + ) + user = user.scalars().first() + if user is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key") + return user + + +async def get_user_by_tokens(session: AsyncSession, token_strs: list[str]) -> list[User]: + users = await session.execute( + select(User) + .join(Token) + .where(Token.token.in_(token_strs), Token.is_active == True) # noqa: E712 + .options(joinedload(User.token)) + ) + return list(users.scalars().all()) + + +async def list_tokens( + session: AsyncSession, token_str: list[str] | None = None, include_inactive: bool = False +) -> list[Token]: + query = select(Token) + if token_str is not None: + query = query.where(Token.token.in_(token_str)) + if not include_inactive: + query = query.where(Token.is_active == True) # noqa: E712 + + result = await session.execute(query) + return list(result.scalars().all()) + + +async def try_create_user( + session: AsyncSession, + token_str: str, + permissions: list[str], + expires_at: datetime | None, + creation_ip: str = "unknown", +) -> User: + existing = await session.execute(select(Token).where(Token.token == token_str, Token.is_active == True)) # noqa: E712 + if existing.scalars().first(): + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Token already exists") + + new_token = Token(token=token_str, permissions=permissions, expires_at=expires_at, created_at=datetime.now(UTC)) + new_user = User(token=new_token, creation_ip=creation_ip) + new_token.user = new_user + + session.add(new_token) + session.add(new_user) + await session.commit() + await session.refresh(new_user, attribute_names=["token"]) + return new_user + + +async def try_delete_token(session: AsyncSession, token_str: list[str]) -> None: + LOGGER.info(f"Attempting to delete tokens: {token_str}") + token = await session.execute(select(Token).where(Token.token.in_(token_str))) + tokens = token.scalars().all() + if tokens: + for token in tokens: + await session.delete(token) + + # If no tokens were found, consider it a successful deletion + + +async def update_user_ip(session: AsyncSession, user: User, ip: str): + if ip not in user.common_ips: + if len(user.common_ips) >= USER_CONFIG.max_ip_records: + user.common_ips.pop(0) + user.common_ips.append(ip) + else: + user.common_ips.remove(ip) + user.common_ips.append(ip) diff --git a/app/docs/openapi.py b/app/docs/openapi.py deleted file mode 100644 index 4571fbb..0000000 --- a/app/docs/openapi.py +++ /dev/null @@ -1,45 +0,0 @@ -from app.core.metadata import EndpointMetadata -from app.domain.constants import __DESCRIPTION__, __NAME__, __VERSION__ -from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi -from fastapi.routing import APIRoute - - -def custom_openapi(app: FastAPI): - def openapi(): - nonlocal app - - if app.openapi_schema: - return app.openapi_schema - openapi_schema = get_openapi( - title=__NAME__, - version=__VERSION__, - summary=f"{__NAME__} API Documentation", - description=__DESCRIPTION__, - routes=app.routes, - ) - - for route in app.routes: - if isinstance(route, APIRoute): - path = route.path - method = route.methods.pop().lower() - endpoint = route.endpoint - permissions = [] - - if hasattr(endpoint, "__metadata__"): - meta = getattr(endpoint, "__metadata__") - if isinstance(meta, EndpointMetadata) and meta.permission: - permissions.append(meta.permission) - - if permissions: - desc = openapi_schema["paths"][path][method].get("description", "") - perms_str = "\n\n🛡 Required Permission(s): " + ", ".join(f"`{p}`" for p in permissions) - openapi_schema["paths"][path][method]["description"] = desc + perms_str - - # Tag groups - openapi_schema["x-tagGroups"] = [{"name": "v1", "tags": ["management", "pool"]}] - - app.openapi_schema = openapi_schema - return app.openapi_schema - - return openapi diff --git a/app/domain/enums/__init__.py b/app/domain/enums/__init__.py deleted file mode 100644 index db61911..0000000 --- a/app/domain/enums/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .error import ErrorCodes -from .pool import LootPoolRegion, LootPoolType, RaidRegion, Rarity - -__all__ = [ - "LootPoolType", - "LootPoolRegion", - "Rarity", - "RaidRegion", - "ErrorCodes", -] diff --git a/app/domain/enums/error.py b/app/domain/enums/error.py deleted file mode 100644 index c44f28f..0000000 --- a/app/domain/enums/error.py +++ /dev/null @@ -1,12 +0,0 @@ -import enum - - -class ErrorCodes(enum.IntEnum): - """ - Enum for error codes. - """ - - OK = 0 - UNKNOWN_ERROR = -1 - VALIDATION_ERROR = -2 - NOT_FOUND = -3 diff --git a/app/domain/response/__init__.py b/app/domain/response/__init__.py deleted file mode 100644 index b7c2c14..0000000 --- a/app/domain/response/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE -from .v1 import Data, StatusResponse -from .v1 import V1Response as Response - -__all__ = [ - "STATUS_RESPONSE", - "EMPTY_RESPONSE", - "Response", - "StatusResponse", - "Data", -] diff --git a/app/domain/response/default_response.py b/app/domain/response/default_response.py deleted file mode 100644 index 5744bcc..0000000 --- a/app/domain/response/default_response.py +++ /dev/null @@ -1,6 +0,0 @@ -from .v1 import EMPTY_RESPONSE, STATUS_RESPONSE - -__all__ = [ - "STATUS_RESPONSE", - "EMPTY_RESPONSE", -] diff --git a/app/domain/response/v1/__init__.py b/app/domain/response/v1/__init__.py deleted file mode 100644 index 50de9c3..0000000 --- a/app/domain/response/v1/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .data import Data -from .default_response import V1_Empty_Response as EMPTY_RESPONSE -from .default_response import V1_Status_Response as STATUS_RESPONSE -from .response import StatusResponse, V1Response - -__all__ = ["V1Response", "StatusResponse", "Data", "EMPTY_RESPONSE", "STATUS_RESPONSE"] diff --git a/app/domain/response/v1/data.py b/app/domain/response/v1/data.py deleted file mode 100644 index 55c3618..0000000 --- a/app/domain/response/v1/data.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel - - -class Data(BaseModel): - """ - Base data model for v1 responses. - This can be extended to include common fields for all responses. - """ - - pass diff --git a/app/domain/response/v1/default_response.py b/app/domain/response/v1/default_response.py deleted file mode 100644 index addb76a..0000000 --- a/app/domain/response/v1/default_response.py +++ /dev/null @@ -1,4 +0,0 @@ -from .response import StatusResponse, V1Response - -V1_Status_Response = StatusResponse() -V1_Empty_Response = V1Response(data={}) diff --git a/app/log.py b/app/log.py deleted file mode 100644 index 6b78967..0000000 --- a/app/log.py +++ /dev/null @@ -1,5 +0,0 @@ -from app.core.log import logger as LOGGER - -__all__ = [ - "LOGGER", -] diff --git a/app/main.py b/app/main.py index b82825a..e87e67f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,57 +1,94 @@ -from contextlib import asynccontextmanager - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import FastAPI, HTTPException -from fastapi.exceptions import RequestValidationError, ResponseValidationError - -from app.core.db import init_db -from app.docs.openapi import custom_openapi -from app.domain.constants import __DESCRIPTION__, __NAME__, __VERSION__ -from app.domain.response import STATUS_RESPONSE, StatusResponse -from app.module.api import V1Router, http_exception_handler, validation_exception_handler - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """ - Lifespan context manager for FastAPI application. - Initializes the scheduler and sets it up to run in the background. - """ - scheduler = AsyncIOScheduler() - scheduler.start() - await init_db() - try: - yield - finally: - scheduler.shutdown(wait=False) - - -app = FastAPI( - title=__NAME__, - description=__DESCRIPTION__, - version=__VERSION__, - docs_url=None, - redoc_url="/docs", - lifespan=lifespan, -) -app.include_router(V1Router) -app.exception_handler(HTTPException)(http_exception_handler) -app.exception_handler(RequestValidationError)(validation_exception_handler) -app.exception_handler(ResponseValidationError)(validation_exception_handler) -app.openapi = custom_openapi(app) - - -@app.get("/") -async def read_root() -> StatusResponse: - return STATUS_RESPONSE - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "app.main:app", - host="0.0.0.0", - port=8000, - log_level="debug", - ) +from contextlib import asynccontextmanager + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError, ResponseValidationError +from fastapi.staticfiles import StaticFiles +from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference + +from app.config import DB_CONFIG +from app.core.db import RedisClient, close_db, init_db +from app.core.openapi import custom_openapi +from app.module.api.exception_handler import ( + generic_exception_handler, + http_exception_handler, + validation_exception_handler, +) +from app.module.api.router import Router +from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ +from app.schemas.response import STATUS_RESPONSE, StatusResponse + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifespan context manager for application. + """ + try: + scheduler = AsyncIOScheduler() + scheduler.start() + await init_db() + if DB_CONFIG.redis_dsn is not None: + await RedisClient.init() + yield + finally: + scheduler.shutdown(wait=False) + await close_db() + if DB_CONFIG.redis_dsn is not None: + await RedisClient.close() + + +app = FastAPI( + title=__NAME__, + description=__DESCRIPTION__, + version=__VERSION__, + docs_url=None, + redoc_url=None, + lifespan=lifespan, + license_info={ + "name": "GNU Affero General Public License v3.0 (AGPL-v3)", + "identifier": "AGPL-3.0-or-later", + "url": "https://www.gnu.org/licenses/agpl-3.0.html", + }, +) + +app.include_router(Router) +app.exception_handler(HTTPException)(http_exception_handler) +app.exception_handler(RequestValidationError)(validation_exception_handler) +app.exception_handler(ResponseValidationError)(validation_exception_handler) +app.exception_handler(Exception)(generic_exception_handler) +app.openapi = custom_openapi(app) + + +@app.get("/") +async def read_root() -> StatusResponse: + return STATUS_RESPONSE + + +@app.get("/docs", include_in_schema=False) +@app.get("/doc", include_in_schema=False) +async def scalar_ui(): + return get_scalar_api_reference( + title=f"{__NAME__} API Documentation", + openapi_url=app.openapi_url, + agent=AgentScalarConfig(disabled=True), + scalar_favicon_url="./static/favicon.ico", + default_open_all_tags=True, + authentication={ + "preferredSecurityScheme": "APIKeyHeader", + }, + ) + + +app.mount("/static", StaticFiles(directory="static"), name="static") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + log_level="debug", + ) diff --git a/app/module/api/__init__.py b/app/module/api/__init__.py deleted file mode 100644 index f259b59..0000000 --- a/app/module/api/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .exception_handler import v1_http_exception_handler as http_exception_handler -from .exception_handler import v1_validation_exception_handler as validation_exception_handler -from .router import V1Router - -__all__ = [ - "V1Router", - "http_exception_handler", - "validation_exception_handler", -] diff --git a/app/module/api/exception_handler.py b/app/module/api/exception_handler.py index fa8b215..e5e7901 100644 --- a/app/module/api/exception_handler.py +++ b/app/module/api/exception_handler.py @@ -1,28 +1,40 @@ -from app.domain.enums import ErrorCodes -from app.domain.response import Response -from app.log import LOGGER -from fastapi import HTTPException, Request -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY - - -def v1_http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: - """ - Global exception handler for v1 API. - This function can be extended to handle specific exceptions and return appropriate responses. - """ - return Response[dict](data=jsonable_encoder(obj=exc), code=ErrorCodes.UNKNOWN_ERROR).to_response(exc.status_code) - - -def v1_validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: - """ - Validation exception handler for v1 API. - This function can be extended to handle validation errors and return appropriate responses. - """ - LOGGER.exception(exc) - return Response[list]( - data=jsonable_encoder(exc.errors()), - code=ErrorCodes.VALIDATION_ERROR, - ).to_response(HTTP_422_UNPROCESSABLE_ENTITY) +from fastapi import HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT, HTTP_500_INTERNAL_SERVER_ERROR + +from app.core.log import LOGGER +from app.schemas.enums import ErrorCodes + +from .schema import GenericExceptionResponse, HTTPErrorDefinition, HTTPErrorResponse, ValidationErrorResponse + + +def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Generic exception handler API. + """ + LOGGER.exception(exc) + return GenericExceptionResponse(data={"error": "INTERNAL_ERROR"}, code=ErrorCodes.UNKNOWN_ERROR).to_response( + HTTP_500_INTERNAL_SERVER_ERROR + ) + + +def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """ + Global exception handler API. + """ + LOGGER.error(f"HTTP {exc.status_code}: {exc.detail}") + return HTTPErrorResponse( + data=HTTPErrorDefinition(status_code=exc.status_code, error=exc.detail), code=ErrorCodes.HTTP_ERROR + ).to_response(exc.status_code, exc.headers) + + +def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: + """ + Validation exception handler API. + """ + LOGGER.error(exc) + return ValidationErrorResponse( + data=jsonable_encoder(exc.errors()), + ).to_response(HTTP_422_UNPROCESSABLE_CONTENT) diff --git a/app/module/api/router.py b/app/module/api/router.py index afca546..4b20221 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -1,9 +1,23 @@ -from app.module.manage import ManageRouter -from app.module.pool import PoolRouter -from fastapi import APIRouter - -# API Router for version 1 - -V1Router = APIRouter(prefix="/api/v1") -V1Router.include_router(ManageRouter) -V1Router.include_router(PoolRouter) +from fastapi import APIRouter + +from app.core import metadata +from app.core.router import DocedAPIRoute +from app.module.api.schema import ValidationErrorResponse +from app.module.manage.router import ManageRouter +from app.module.pool.router import PoolRouter +from app.schemas.response import StatusResponse + +Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) +Router.include_router(ManageRouter) +Router.include_router(PoolRouter) + + +@Router.get("/test") +@metadata.rate_limit(10, 60) +@metadata.cached(expire=60) +@metadata.permission("api.test") +async def test_endpoint() -> StatusResponse: + """ + Test endpoint to verify API is working. + """ + return StatusResponse() diff --git a/app/module/api/schema.py b/app/module/api/schema.py new file mode 100644 index 0000000..8e62195 --- /dev/null +++ b/app/module/api/schema.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel +from pydantic_core import ErrorDetails + +from app.schemas.enums import ErrorCodes +from app.schemas.response.response import WCSResponse + + +class GenericExceptionResponse(WCSResponse[dict]): + """ + Generic exception response model. + """ + + +class HTTPErrorDefinition(BaseModel): + status_code: int + error: str + + +class HTTPErrorResponse(WCSResponse[dict]): + """ + HTTP error response model. + """ + + data: HTTPErrorDefinition + code: ErrorCodes = ErrorCodes.HTTP_ERROR + + +class ValidationErrorResponse(WCSResponse[list]): + """ + Validation error response model. + """ + + data: list[ErrorDetails] + code: ErrorCodes = ErrorCodes.VALIDATION_ERROR + + +__all__ = ["GenericExceptionResponse", "HTTPErrorResponse", "ValidationErrorResponse"] diff --git a/app/module/manage/__init__.py b/app/module/manage/__init__.py deleted file mode 100644 index cc4a10b..0000000 --- a/app/module/manage/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .router import ManageRouter - -__all__ = [ - "ManageRouter", -] diff --git a/app/module/manage/domain/response.py b/app/module/manage/domain/response.py deleted file mode 100644 index 11c4521..0000000 --- a/app/module/manage/domain/response.py +++ /dev/null @@ -1,41 +0,0 @@ -from app.domain.response import Data, Response - - -class PermissionListData(Data): - """ - Data model for a list of permissions. - """ - - token: str - permissions: list[str] - - -class PermissionListResponse(Response[PermissionListData]): - """ - Response model for a list of permissions. - """ - - data: PermissionListData - - -class TokenListData(Data): - """ - Data model for a list of tokens. - """ - - tokens: list[str] - - -class TokenListResponse(Response[TokenListData]): - """ - Response model for a list of tokens. - """ - - data: TokenListData - - -__all__ = [ - "PermissionListData", - "PermissionListResponse", - "TokenListData", -] diff --git a/app/module/manage/router.py b/app/module/manage/router.py index e43ae00..4a13079 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -1,220 +1,147 @@ -from typing import Optional - -from app.core.auth import ( - Token, - add_permission_for_token, - add_token, - get_token, - get_user, - list_tokens, - remove_permission_for_token, - remove_token, - require_permission, -) -from app.core.db import get_session -from app.core.metadata import with_metadata -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from .domain.response import ( - PermissionListData, - PermissionListResponse, - TokenListData, - TokenListResponse, -) - -ManageRouter = APIRouter(prefix="/manage", tags=["management"]) - - -# Permission Management Endpoints -@ManageRouter.get( - "/perm/{user_token}/list", - dependencies=[Depends(require_permission)], - summary="Get Permissions for Token", - description="Get the permissions associated with a specific user token.", -) -@with_metadata(permission="management.permissions.read.any") -async def get_permissions( - user_token: str, - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - """ - Get permissions for a given token. - :param token: The token to check permissions for. - :return: A list of permissions associated with the token. - """ - - token = await get_token(session, user_token) - if not token: - return PermissionListResponse(data=PermissionListData(token=user_token, permissions=[])) - permissions = token.permissions - permissions = [perm.permission_id for perm in permissions] - return PermissionListResponse(data=PermissionListData(token=user_token, permissions=permissions)) - - -@ManageRouter.get( - "/perm/list", - summary="Get Self Permissions", - description="Get the permissions associated with the current user's token.", -) -async def get_self_permissions( - token: Token | None = Depends(get_user), - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - if not token: - return PermissionListResponse(data=PermissionListData(token="", permissions=[])) - permissions = token.permissions - permissions = [perm.permission_id for perm in permissions] - return PermissionListResponse(data=PermissionListData(token=token.token, permissions=permissions)) - - -@ManageRouter.put( - "/perm/{user_token}/add", - dependencies=[Depends(require_permission)], - summary="Add Permission to Token", - description="Add a permission to a specific user token.", -) -@with_metadata(permission="management.permissions.write.any") -async def add_permission( - user_token: str, - permission: str, - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - token = await add_permission_for_token(session, user_token, permission) - return PermissionListResponse( - data=PermissionListData(token=user_token, permissions=[perm.permission_id for perm in token.permissions]) - ) - - -@ManageRouter.post( - "/perm/{user_token}/add", - dependencies=[Depends(require_permission)], - summary="Add Permission to Token (POST) (Bulk)", - description="Add permissions to a specific user token using POST method.", -) -@with_metadata(permission="management.permissions.write.any") -async def add_permission_bulk( - user_token: str, - permissions: list[str], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - token = await add_permission_for_token(session, user_token, permissions) - return PermissionListResponse( - data=PermissionListData(token=user_token, permissions=[perm.permission_id for perm in token.permissions]) - ) - - -@ManageRouter.delete( - "/perm/{user_token}/revoke", - dependencies=[Depends(require_permission)], - summary="Revoke Permission from Token", - description="Revoke a permission from a specific user token.", -) -@with_metadata(permission="management.permissions.write.any") -async def revoke_permission( - user_token: str, - permission: str, - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - token = await remove_permission_for_token(session, user_token, permission) - return PermissionListResponse( - data=PermissionListData(token=user_token, permissions=[perm.permission_id for perm in token.permissions]) - ) - - -@ManageRouter.post( - "/perm/{user_token}/revoke", - dependencies=[Depends(require_permission)], - summary="Revoke Permission from Token (POST) (Bulk)", - description="Revoke permissions from a specific user token using POST method.", -) -@with_metadata(permission="management.permissions.write.any") -async def revoke_permission_bulk( - user_token: str, - permissions: list[str], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> PermissionListResponse: - token = await remove_permission_for_token(session, user_token, permissions) - return PermissionListResponse( - data=PermissionListData(token=user_token, permissions=[perm.permission_id for perm in token.permissions]) - ) - - -# Token Management Endpoints -@ManageRouter.get( - "/token/list", - dependencies=[Depends(require_permission)], - summary="List All Tokens", - description="Get a list of all user tokens.", -) -@with_metadata(permission="management.tokens.read.any") -async def list_all_tokens( - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> TokenListResponse: - tokens = await list_tokens(session) - return TokenListResponse(data=TokenListData(tokens=[token.token for token in tokens])) - - -@ManageRouter.put( - "/token/{user_token}/create", - dependencies=[Depends(require_permission)], - summary="Create Token", - description="Create a new user token.", -) -@with_metadata(permission="management.tokens.write.any") -async def create_token( - user_token: str, - permission: Optional[str] = None, - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> TokenListResponse: - if not permission: - await add_token(session, user_token, []) - else: - await add_token(session, user_token, permission) - return TokenListResponse(data=TokenListData(tokens=[user_token])) - - -@ManageRouter.post( - "/token/{user_token}/create", - dependencies=[Depends(require_permission)], - summary="Create Token (POST) (Bulk)", - description="Create new user tokens using POST method.", -) -@with_metadata(permission="management.tokens.write.any") -async def create_token_bulk( - user_tokens: list[str], - permissions: list[str], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> TokenListResponse: - await add_token(session, user_tokens, permissions) - return TokenListResponse(data=TokenListData(tokens=user_tokens)) - - -@ManageRouter.delete( - "/token/{user_token}/remove", - dependencies=[Depends(require_permission)], - summary="Remove Token", - description="Remove a specific user token.", -) -@with_metadata(permission="management.tokens.write.any") -async def delete_token( - user_token: str, - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> TokenListResponse: - await remove_token(session, user_token) - return TokenListResponse(data=TokenListData(tokens=[user_token])) - - -@ManageRouter.post( - "/token/{user_token}/remove", - dependencies=[Depends(require_permission)], - summary="Remove Token (POST) (Bulk)", - description="Remove specific user tokens using POST method.", -) -@with_metadata(permission="management.tokens.write.any") -async def delete_token_bulk( - user_tokens: list[str], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> TokenListResponse: - await remove_token(session, user_tokens) - return TokenListResponse(data=TokenListData(tokens=user_tokens)) +from typing import Annotated + +from fastapi import APIRouter, Depends, Header + +import app.core.metadata as metadata +from app.core.db import get_session +from app.core.log import LOGGER +from app.core.router import DocedAPIRoute +from app.core.security.auth import get_user, hash_token, hash_tokens +from app.core.security.model import ( + User, + get_user_by_tokens, + list_tokens, + try_create_user, + try_delete_token, +) +from app.schemas.response import EMPTY_RESPONSE, WCSResponse + +from .schema import TokenInfo, TokenInfoResponse, UserInfoResponse + +ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=["management"]) + + +@ManageRouter.get("/tokens", summary="List API Tokens") +@metadata.permission("admin.tokens.read") +async def get_tokens(inactive: bool = False, session=Depends(get_session)) -> WCSResponse[list[TokenInfoResponse]]: + """ + List all API tokens with their permissions. + """ + tokens = await list_tokens(session, include_inactive=inactive) + return WCSResponse(data=[TokenInfoResponse.from_orm(token) for token in tokens]) + + +@ManageRouter.post("/tokens", summary="Create API Token") +@metadata.permission(permission="admin.tokens.write") +async def create_token(tokens: list[TokenInfo], session=Depends(get_session)) -> WCSResponse[list[TokenInfoResponse]]: + """ + Create a new API token with specified permissions and expiration. + """ + created_tokens: list[TokenInfoResponse] = [] + for token_info in tokens: + user = await try_create_user( + session, + token_str=hash_token(token_info.token), + permissions=token_info.permissions, + expires_at=token_info.expires_at, + ) + created_tokens.append(TokenInfoResponse.from_orm(user.token)) + + LOGGER.info(f"Created {len(created_tokens)} new API tokens: {[t.token for t in created_tokens]}") + return WCSResponse(data=created_tokens) + + +@ManageRouter.delete("/tokens", summary="Delete API Token") +@metadata.permission(permission="admin.tokens.write") +async def delete_token(tokens: list[str], session=Depends(get_session)) -> WCSResponse[dict]: + """ + Delete an API token by its token string. + """ + tokens = hash_tokens(tokens) + await try_delete_token(session, tokens) + LOGGER.info(f"Deleted {len(tokens)} API tokens: {tokens}") + return EMPTY_RESPONSE + + +@ManageRouter.put("/tokens/permissions", summary="Add Permissions to Token") +@metadata.permission(permission="admin.tokens.write") +async def add_permissions_to_token( + token_str: list[str], permissions: list[str], session=Depends(get_session) +) -> WCSResponse[list[TokenInfoResponse]]: + """ + Add permissions to existing API tokens. + """ + token_strs = hash_tokens(token_str) + tokens = await list_tokens(session, token_strs, include_inactive=True) + + for token in tokens: + token.permissions = list(set(token.permissions + permissions)) + + updated_token_infos = [TokenInfoResponse.from_orm(token) for token in tokens] + LOGGER.info(f"Added permissions {permissions} to tokens: {token_strs}") + return WCSResponse(data=updated_token_infos) + + +@ManageRouter.delete("/tokens/permissions", summary="Remove Permissions from Token") +@metadata.permission(permission="admin.tokens.write") +async def remove_permissions_from_token( + token_str: list[str], permissions: list[str], session=Depends(get_session) +) -> WCSResponse[list[TokenInfoResponse]]: + """ + Remove permissions from existing API tokens. + """ + token_strs = hash_tokens(token_str) + tokens = await list_tokens(session, token_strs, include_inactive=True) + + for token in tokens: + token.permissions = list(set(token.permissions) - set(permissions)) + + updated_token_infos = [TokenInfoResponse.from_orm(token) for token in tokens] + LOGGER.info(f"Removed permissions {permissions} from tokens: {token_strs}") + return WCSResponse(data=updated_token_infos) + + +@ManageRouter.get("/tokens/self", summary="Get Self Token Info") +async def get_self_token_info(user: User = Depends(get_user)) -> WCSResponse[TokenInfoResponse]: + """ + Get information about the API token used in the current request. + """ + return WCSResponse(data=TokenInfoResponse.from_orm(user.token)) + + +@ManageRouter.get("/user/self", summary="Get Self User Info") +async def get_self_user_info(user: User = Depends(get_user)) -> WCSResponse[UserInfoResponse]: + """ + Get information about the current user, including permissions and common IPs. + """ + return WCSResponse(data=UserInfoResponse.from_orm(user)) + + +@ManageRouter.get("/user", summary="Get User Info by Token") +@metadata.permission(permission="admin.tokens.read") +async def get_user_info_by_token(token: list[str], session=Depends(get_session)) -> WCSResponse[list[UserInfoResponse]]: + """ + Get information about a user by their API token. + """ + tokens = hash_tokens(token) + + users = await get_user_by_tokens(session, tokens) + return WCSResponse(data=[UserInfoResponse.from_orm(user) for user in users]) + + +@ManageRouter.post("/register", summary="Register New User") +@metadata.rate_limit(1, 3600) +async def register_user( + token: str, + x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, + session=Depends(get_session), +) -> WCSResponse[UserInfoResponse]: + """ + Register a new user with the given token. + """ + hashed_token = hash_token(token) + user = await try_create_user( + session, token_str=hashed_token, permissions=[], expires_at=None, creation_ip=x_real_ip or "unknown" + ) + LOGGER.info(f"Registered new user with token: {token}") + return WCSResponse(data=UserInfoResponse.from_orm(user)) diff --git a/app/module/manage/schema.py b/app/module/manage/schema.py new file mode 100644 index 0000000..c99ce00 --- /dev/null +++ b/app/module/manage/schema.py @@ -0,0 +1,47 @@ +import datetime +from typing import Self + +from pydantic import BaseModel + +from app.core.security.model import Token, User + + +class TokenInfo(BaseModel): + token: str + permissions: list[str] + expires_at: datetime.datetime | None + + @classmethod + def from_orm(cls, obj: Token) -> Self: + return cls( + token=obj.token, + permissions=obj.permissions, + expires_at=obj.expires_at, + ) + + +class TokenInfoResponse(TokenInfo): + created_at: datetime.datetime + + @classmethod + def from_orm(cls, obj: Token) -> Self: + return cls( + token=obj.token, + permissions=obj.permissions, + expires_at=obj.expires_at, + created_at=obj.created_at, + ) + + +class UserInfoResponse(BaseModel): + token: TokenInfoResponse + score: int + common_ips: list[str] + + @classmethod + def from_orm(cls, obj: User) -> Self: + return cls( + token=TokenInfoResponse.from_orm(obj.token), + score=obj.score, + common_ips=obj.common_ips, + ) diff --git a/app/module/pool/__init__.py b/app/module/pool/__init__.py deleted file mode 100644 index e74a870..0000000 --- a/app/module/pool/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .router import PoolRouter - -__all__ = [ - "PoolRouter", -] diff --git a/app/module/pool/config.py b/app/module/pool/config.py new file mode 100644 index 0000000..ecc0fbe --- /dev/null +++ b/app/module/pool/config.py @@ -0,0 +1,26 @@ +from datetime import time, timedelta + +from .schema import LootPoolType + +POOL_REFRESH_CONFIG = { + LootPoolType.ITEM: { + "interval": timedelta(days=7), + "anchor": time(0, 0, 0), + }, + LootPoolType.RAID_ASPECT: { + "interval": timedelta(hours=24), + "anchor": time(0, 0, 0), + }, + LootPoolType.RAID_TOME: { + "interval": timedelta(hours=24), + "anchor": time(0, 0, 0), + }, +} + +FUZZY_WINDOW = timedelta(minutes=10) + +SCORE_CONFIG = { + "min": -10000, + "max": 10000, + # TODO more sophisticated scoring config +} diff --git a/app/module/pool/domain/common.py b/app/module/pool/domain/common.py deleted file mode 100644 index e60662b..0000000 --- a/app/module/pool/domain/common.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class ShinyData(BaseModel): - item: str - tracker: str diff --git a/app/module/pool/domain/request.py b/app/module/pool/domain/request.py deleted file mode 100644 index e48206c..0000000 --- a/app/module/pool/domain/request.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Optional - -from app.domain.enums import LootPoolType, Rarity -from pydantic import BaseModel - -from .common import ShinyData - - -class CrowdSourceLootPoolData(BaseModel): - type: LootPoolType - location: str - page: int - shiny: Optional[ShinyData] = None - items: dict[Rarity, list[str]] diff --git a/app/module/pool/domain/response.py b/app/module/pool/domain/response.py deleted file mode 100644 index 75921f7..0000000 --- a/app/module/pool/domain/response.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Optional - -from app.domain.enums import LootPoolRegion, RaidRegion, Rarity -from app.domain.response import Response -from pydantic import BaseModel, ConfigDict - -from .common import ShinyData - - -class LootPoolPagedData(BaseModel): - shiny: Optional[ShinyData] = None - items: dict[Rarity, list[str]] - - model_config = ConfigDict(use_enum_values=True) - - -class ItemLootPoolData(BaseModel): - loot: dict[LootPoolRegion, dict[int, LootPoolPagedData]] - - model_config = ConfigDict(use_enum_values=True) - - -class ItemLootPoolResponse(Response[ItemLootPoolData]): - """ - Response model for item loot pools. - """ - - -class RaidLootPoolPagedData(BaseModel): - items: dict[Rarity, list[str]] - - model_config = ConfigDict(use_enum_values=True) - - -# Could be used for both aspects and tomes -class RaidLootPoolData(BaseModel): - loot: dict[RaidRegion, dict[int, RaidLootPoolPagedData]] - - model_config = ConfigDict(use_enum_values=True) - - -class RaidLootPoolResponse(Response[RaidLootPoolData]): - """ - Response model for raid loot pools. - """ - - -__all__ = [ - "ShinyData", - "LootPoolPagedData", - "ItemLootPoolData", - "ItemLootPoolResponse", - "RaidLootPoolData", - "RaidLootPoolResponse", -] diff --git a/app/module/pool/model.py b/app/module/pool/model.py index 116d65c..81bcb92 100644 --- a/app/module/pool/model.py +++ b/app/module/pool/model.py @@ -1,100 +1,55 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + from app.core.db import Base -from app.domain.enums import LootPoolType -from sqlalchemy import JSON, BigInteger, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from sqlalchemy.orm import ( - Mapped, - mapped_column, -) - - -class LootPoolData(Base): - """ - Data model for loot pools. - """ - - __tablename__ = "loot_pools" - - id: Mapped[int] = mapped_column(primary_key=True) - starting_date: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) - lr_item_pool: Mapped[dict] = mapped_column(JSON, nullable=False, default={}) - raid_aspect_pool: Mapped[dict] = mapped_column(JSON, nullable=False, default={}) - raid_tome_pool: Mapped[dict] = mapped_column(JSON, nullable=False, default={}) - - -async def add_loot_pools( - async_session: async_sessionmaker[AsyncSession], - loot_pool_data: LootPoolData, -) -> None: - async with async_session() as session: - async with session.begin(): - stmt = select(LootPoolData).where(LootPoolData.starting_date == loot_pool_data.starting_date) - result = (await session.execute(stmt)).unique().scalar_one_or_none() - if result: - # Update existing record - result.lr_item_pool = loot_pool_data.lr_item_pool - result.raid_aspect_pool = loot_pool_data.raid_aspect_pool - result.raid_tome_pool = loot_pool_data.raid_tome_pool - else: - # Add new record - session.add(loot_pool_data) - await session.commit() - - -async def get_loot_pools( - async_session: async_sessionmaker[AsyncSession], - rotation_date: int, -) -> LootPoolData | None: - async with async_session() as session: - stmt = select(LootPoolData).where(LootPoolData.starting_date == rotation_date) - result = (await session.execute(stmt)).unique().scalar_one_or_none() - return result - - -async def update_loot_pools( - async_session: async_sessionmaker[AsyncSession], - loot_pool_type: LootPoolType, - loot_pool_data: dict, - rotation_date: int, -) -> None: - async with async_session() as session: - async with session.begin(): - match loot_pool_type: - case LootPoolType.ITEM: - stmt = select(LootPoolData).where(LootPoolData.starting_date == rotation_date) - result = (await session.execute(stmt)).unique().scalar_one_or_none() - if result: - result.lr_item_pool = loot_pool_data - case LootPoolType.RAID_ASPECT: - stmt = select(LootPoolData).where(LootPoolData.starting_date == rotation_date) - result = (await session.execute(stmt)).unique().scalar_one_or_none() - if result: - result.raid_aspect_pool = loot_pool_data - case LootPoolType.RAID_TOME: - stmt = select(LootPoolData).where(LootPoolData.starting_date == rotation_date) - result = (await session.execute(stmt)).unique().scalar_one_or_none() - if result: - result.raid_tome_pool = loot_pool_data - await session.commit() - - -async def purge_loot_pools( - async_session: async_sessionmaker[AsyncSession], - rotation_date: int, -) -> None: - async with async_session() as session: - async with session.begin(): - stmt = select(LootPoolData).where(LootPoolData.starting_date == rotation_date) - result = (await session.execute(stmt)).unique().scalars().all() - for loot_pool in result: - await session.delete(loot_pool) - await session.commit() - - -__all__ = [ - "LootPoolData", - "add_loot_pools", - "get_loot_pools", - "update_loot_pools", - "purge_loot_pools", -] +from app.core.security.model import User + + +class Pool(Base): + __tablename__ = "pools" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + pool_type: Mapped[str] = mapped_column(String(50), nullable=False) + region: Mapped[str] = mapped_column(String(50), nullable=False) + + rotation_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + rotation_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + consensus_data: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + submission_count: Mapped[int] = mapped_column(Integer, default=0) + + needs_recalc: Mapped[bool] = mapped_column(Boolean, default=True) + + submissions: Mapped[list["PoolSubmission"]] = relationship( + "PoolSubmission", + back_populates="rotation", + cascade="save-update, merge", + ) + + __table_args__ = (UniqueConstraint("pool_type", "region", "rotation_start", name="uq_pool_rotation_key"),) + + +class PoolSubmission(Base): + __tablename__ = "pool_submissions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + rotation_id: Mapped[int] = mapped_column(ForeignKey("pools.id", ondelete="CASCADE"), index=True, nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + submitted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + client_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + item_data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + weight: Mapped[float] = mapped_column(Float, nullable=False) + is_fuzzy: Mapped[bool] = mapped_column(Boolean, default=False) + + rotation: Mapped["Pool"] = relationship("Pool", back_populates="submissions") + user: Mapped["User"] = relationship("User") diff --git a/app/module/pool/router.py b/app/module/pool/router.py index dcdc76b..baf5197 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -1,121 +1,3 @@ -from typing import Any - -from app.core.auth import require_permission -from app.core.db import get_session -from app.core.metadata import with_metadata -from app.domain.enums import LootPoolType -from app.domain.response import Response -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from .domain.request import CrowdSourceLootPoolData -from .domain.response import ( - ItemLootPoolData, - ItemLootPoolResponse, - RaidLootPoolData, - RaidLootPoolResponse, -) -from .model import get_loot_pools, purge_loot_pools, update_loot_pools -from .service import crowdsource_loot_pool, get_current_rotation_date - -PoolRouter = APIRouter(prefix="/pool", tags=["pool"]) - - -@PoolRouter.get( - "/item/list", - summary="Get Item Loot Pools", - description="Get the loot pools for items, including shiny items and their rarities.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.item.read") -async def get_item_loot_pools( - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> ItemLootPoolResponse: - loot_pool_data = await get_loot_pools(session, get_current_rotation_date()) - if loot_pool_data is None: - return ItemLootPoolResponse(data=ItemLootPoolData(loot={})) - return ItemLootPoolResponse(data=ItemLootPoolData(**loot_pool_data.lr_item_pool)) - - -@PoolRouter.get( - "/raid/aspect/list", - summary="Get Raid Aspect Loot Pools", - description="Get the loot pools for raid aspects.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.raid.aspect.read") -async def get_raid_aspect_loot_pools( - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> RaidLootPoolResponse: - loot_pool_data = await get_loot_pools(session, get_current_rotation_date()) - if loot_pool_data is None: - return RaidLootPoolResponse(data=RaidLootPoolData(loot={})) - return RaidLootPoolResponse(data=RaidLootPoolData(**loot_pool_data.raid_aspect_pool)) - - -@PoolRouter.get( - "/raid/tome/list", - summary="Get Raid Tome Loot Pools", - description="Get the loot pools for raid tomes.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.raid.tome.read") -async def get_raid_tome_loot_pools( - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> RaidLootPoolResponse: - loot_pool_data = await get_loot_pools(session, get_current_rotation_date()) - if loot_pool_data is None: - return RaidLootPoolResponse(data=RaidLootPoolData(loot={})) - return RaidLootPoolResponse(data=RaidLootPoolData(**loot_pool_data.raid_tome_pool)) - - -@PoolRouter.post( - "/crowdsource", - summary="Crowdsource Loot Pool", - description="Upload Crowdsource data for the loot pool for a specific location and page.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.item.crowdsource") -async def crowdsource_item_loot_pool( - items: list[CrowdSourceLootPoolData], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> Response: - for item in items: - await crowdsource_loot_pool(item.type, item.location, item.page, item, session) - return Response.from_message( - message=f"Successfully crowdsourced {len(items)} pages for loot pool.", - ) - - -@PoolRouter.delete( - "/clear", - summary="Purge Current Loot Pool", - description="Purge the current loot pool.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.item.write") -async def purge_item_loot_pool( - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> Response: - await purge_loot_pools(session, get_current_rotation_date()) - return Response.from_message( - message=f"Purged current loot pool for date {get_current_rotation_date()}", - ) - - -@PoolRouter.post( - "/{type}/update", - summary="Update Loot Pool", - description="Manual update the loot pool for a specific type.", - dependencies=[Depends(require_permission)], -) -@with_metadata(permission="pool.item.write") -async def update_loot_pool( - type: LootPoolType, - loot_pool_data: dict[str, Any], - session: async_sessionmaker[AsyncSession] = Depends(get_session), -) -> Response: - await update_loot_pools(session, type, loot_pool_data, get_current_rotation_date()) - return Response.from_message( - message=f"Updated loot pool for type {type} with data: {loot_pool_data}", - ) +from fastapi import APIRouter + +PoolRouter = APIRouter(prefix="/pool", tags=["pool"]) diff --git a/app/domain/enums/pool.py b/app/module/pool/schema.py similarity index 74% rename from app/domain/enums/pool.py rename to app/module/pool/schema.py index ac79767..fab29c7 100644 --- a/app/domain/enums/pool.py +++ b/app/module/pool/schema.py @@ -29,3 +29,10 @@ class RaidRegion(enum.StrEnum): TCC = "TCC" NOL = "NOL" NOTG = "NOTG" + + +VALID_REGIONS: dict[LootPoolType, type[enum.StrEnum]] = { + LootPoolType.ITEM: LootPoolRegion, + LootPoolType.RAID_ASPECT: RaidRegion, + LootPoolType.RAID_TOME: RaidRegion, +} diff --git a/app/module/pool/service.py b/app/module/pool/service.py index 6f120a4..e69de29 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -1,90 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Any - -from app.core.db import get_session -from app.domain.enums import LootPoolType -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from .domain.request import CrowdSourceLootPoolData -from .model import LootPoolData, add_loot_pools, get_loot_pools - - -def get_current_rotation_date(): - # Get the last Friday, 7:00:00 PM GMT - return int(get_current_rotation_datetime().timestamp()) # Convert to seconds - - -def get_current_rotation_datetime() -> datetime: - now = datetime.now(timezone.utc) - last_friday = now - timedelta(days=(now.weekday() + 2) % 7) - last_friday = last_friday.replace(hour=19, minute=0, second=0, microsecond=0) - return last_friday - - -async def crowdsource_loot_pool( - type: LootPoolType, - location: str, - page: int, - item_data: CrowdSourceLootPoolData, - session: async_sessionmaker[AsyncSession], -) -> None: - # Use the simplest update logic for the time being - pool = await get_loot_pools(session, get_current_rotation_date()) - if pool is None: - # Create a new pool if it doesn't exist - pool = LootPoolData( - starting_date=get_current_rotation_date(), - lr_item_pool={"loot": {}}, - raid_aspect_pool={"loot": {}}, - raid_tome_pool={"loot": {}}, - ) - items = item_data.model_dump(exclude_none=True, exclude_unset=True, include={"shiny", "items"}) - match type: - case LootPoolType.ITEM: - if not pool.lr_item_pool["loot"].get(location): - pool.lr_item_pool["loot"][location] = {} - pool.lr_item_pool["loot"][location][page] = rotation_strategy( - previous=pool.lr_item_pool["loot"].get(location, {}).get(page, {}), - now=items, - ) - case LootPoolType.RAID_ASPECT: - if not pool.raid_aspect_pool["loot"].get(location): - pool.raid_aspect_pool["loot"][location] = {} - pool.raid_aspect_pool["loot"][location][page] = rotation_strategy( - previous=pool.raid_aspect_pool["loot"].get(location, {}).get(page, {}), - now=items, - ) - case LootPoolType.RAID_TOME: - if not pool.raid_tome_pool["loot"].get(location): - pool.raid_tome_pool["loot"][location] = {} - pool.raid_tome_pool["loot"][location][page] = rotation_strategy( - previous=pool.raid_tome_pool["loot"].get(location, {}).get(page, {}), - now=items, - ) - - await add_loot_pools(get_session(), pool) - - -def rotation_strategy( - previous: dict[str, Any], - now: dict[str, Any], -) -> dict[str, Any]: - """ - Update the loot pool with the given type, location, page, and items. - This function is used to update the loot pool data for different types of loot. - """ - # TODO Implement a more complex rotation strategy if needed - if datetime.now(timezone.utc) - timedelta(hours=3) > get_current_rotation_datetime(): - # Grace period of 3 hours after the rotation - # The new data might still be old - previous.update(now) - else: - # The new data is fresh - previous.update(now) - return now - - -__all__ = [ - "get_current_rotation_date", - "crowdsource_loot_pool", -] diff --git a/app/domain/constants.py b/app/schemas/constants.py similarity index 86% rename from app/domain/constants.py rename to app/schemas/constants.py index d18eb0b..33c0d61 100644 --- a/app/domain/constants.py +++ b/app/schemas/constants.py @@ -1,34 +1,36 @@ -import enum - -import tomllib - - -class MediaType(enum.StrEnum): - JSON = "application/json" - HTML = "text/html" - TEXT = "text/plain" - XML = "application/xml" - FORM_URLENCODED = "application/x-www-form-urlencoded" - MULTIPART_FORM_DATA = "multipart/form-data" - - @classmethod - def get_media_type(cls, media_type: str) -> str: - return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value - - -with open("pyproject.toml", "rb") as f: - pyproject = tomllib.load(f) - -__NAME__ = "WynnSource Server" -__VERSION__: str = pyproject["project"]["version"] -__DESCRIPTION__: str = pyproject["project"]["description"] -__REVISION__ = 1 - - -__all__ = [ - "MediaType", - "__NAME__", - "__VERSION__", - "__DESCRIPTION__", - "__REVISION__", -] +import enum +import tomllib + + +class MediaType(enum.StrEnum): + JSON = "application/json" + HTML = "text/html" + TEXT = "text/plain" + XML = "application/xml" + FORM_URLENCODED = "application/x-www-form-urlencoded" + MULTIPART_FORM_DATA = "multipart/form-data" + + @classmethod + def get_media_type(cls, media_type: str) -> str: + return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value + + +with open("pyproject.toml", "rb") as f: + pyproject = tomllib.load(f) + +__NAME__ = "WynnSource Server" +__VERSION__: str = pyproject["project"]["version"] +__DESCRIPTION__: str = pyproject["project"]["description"] +__REVISION__ = 2 + + +INJECTED_NAMESPACE = "__wcs_injected_" + +__all__ = [ + "INJECTED_NAMESPACE", + "__DESCRIPTION__", + "__NAME__", + "__REVISION__", + "__VERSION__", + "MediaType", +] diff --git a/app/schemas/enums/__init__.py b/app/schemas/enums/__init__.py new file mode 100644 index 0000000..375425b --- /dev/null +++ b/app/schemas/enums/__init__.py @@ -0,0 +1,5 @@ +from .error import ErrorCodes + +__all__ = [ + "ErrorCodes", +] diff --git a/app/schemas/enums/error.py b/app/schemas/enums/error.py new file mode 100644 index 0000000..07e5b67 --- /dev/null +++ b/app/schemas/enums/error.py @@ -0,0 +1,13 @@ +import enum + + +class ErrorCodes(enum.StrEnum): + """ + Enum for error codes. + """ + + OK = "OK" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + VALIDATION_ERROR = "VALIDATION_ERROR" + NOT_FOUND = "NOT_FOUND" + HTTP_ERROR = "HTTP_ERROR" diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py new file mode 100644 index 0000000..893b365 --- /dev/null +++ b/app/schemas/response/__init__.py @@ -0,0 +1,4 @@ +from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE +from .response import StatusResponse, WCSResponse + +__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "StatusResponse", "WCSResponse"] diff --git a/app/schemas/response/default_response.py b/app/schemas/response/default_response.py new file mode 100644 index 0000000..ac72c56 --- /dev/null +++ b/app/schemas/response/default_response.py @@ -0,0 +1,4 @@ +from .response import StatusResponse, WCSResponse + +STATUS_RESPONSE = StatusResponse() +EMPTY_RESPONSE = WCSResponse(data={}) diff --git a/app/domain/response/v1/response.py b/app/schemas/response/response.py similarity index 53% rename from app/domain/response/v1/response.py rename to app/schemas/response/response.py index 40ec482..51198f0 100644 --- a/app/domain/response/v1/response.py +++ b/app/schemas/response/response.py @@ -1,66 +1,66 @@ -from datetime import datetime, timezone - -from app.domain.constants import __VERSION__ -from app.domain.enums import ErrorCodes -from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field -from starlette.status import HTTP_200_OK - -from .data import Data - - -class V1Response[T: (BaseModel, dict, list)](BaseModel): - """ - Base class for all v1 response models. - """ - - data: T - code: ErrorCodes = Field(default=ErrorCodes.OK) - timestamp: int = Field(default_factory=lambda: int(datetime.now(timezone.utc).timestamp()), frozen=True) - version: int = Field(default=1, frozen=True) - - def to_response(self, response_code: int = HTTP_200_OK): - return JSONResponse( - status_code=response_code, - content=self.model_dump(mode="json"), - ) - - @classmethod - def from_message(cls, message: str) -> "V1Response[dict]": - """ - Create a response with a message. - """ - return V1Response[dict]( - data={"message": message}, - code=ErrorCodes.OK, - timestamp=int(datetime.now(timezone.utc).timestamp()), - ) - - @classmethod - def from_dict(cls, data: dict) -> "V1Response[dict]": - """ - Create a response from a dictionary. - """ - return V1Response[dict]( - data=data, - code=ErrorCodes.OK, - timestamp=int(datetime.now(timezone.utc).timestamp()), - ) - - -class StatusData(Data): - """ - Status data model for v1 responses. - """ - - # TODO DB connection status - status: str = Field(default="OK", frozen=True) - version: str = Field(default=__VERSION__, frozen=True) - - -class StatusResponse(V1Response[StatusData]): - """ - Status response model for v1. - """ - - data: StatusData = Field(default=StatusData(), frozen=True) +from collections.abc import Mapping +from datetime import UTC, datetime + +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from starlette.status import HTTP_200_OK + +from app.schemas.constants import __REVISION__, __VERSION__ +from app.schemas.enums import ErrorCodes + + +class WCSResponse[T: (BaseModel, dict, list)](BaseModel): + """ + Base class for all v1 response models. + """ + + data: T + code: ErrorCodes = Field(default=ErrorCodes.OK) + timestamp: int = Field(default_factory=lambda: int(datetime.now(UTC).timestamp()), frozen=True) + version: int = Field(default=__REVISION__, frozen=True) + + def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse: + return JSONResponse( + status_code=response_code, + content=self.model_dump(mode="json"), + headers=headers, + ) + + @classmethod + def from_message(cls, message: str) -> "WCSResponse[dict]": + """ + Create a response with a message. + """ + return WCSResponse[dict]( + data={"message": message}, + code=ErrorCodes.OK, + timestamp=int(datetime.now(UTC).timestamp()), + ) + + @classmethod + def from_dict(cls, data: dict) -> "WCSResponse[dict]": + """ + Create a response from a dictionary. + """ + return WCSResponse[dict]( + data=data, + code=ErrorCodes.OK, + timestamp=int(datetime.now(UTC).timestamp()), + ) + + +class StatusData(BaseModel): + """ + Status data model for v1 responses. + """ + + status: str = Field(default="OK", frozen=True) + version: str = Field(default=__VERSION__, frozen=True) + + +class StatusResponse(WCSResponse[StatusData]): + """ + Status response model for v1. + """ + + data: StatusData = Field(default=StatusData(), frozen=True) diff --git a/app/utils/time_utils.py b/app/utils/time_utils.py new file mode 100644 index 0000000..b4f105c --- /dev/null +++ b/app/utils/time_utils.py @@ -0,0 +1,13 @@ +def format_time(seconds: int) -> str: + """Format a time duration in seconds into a human-readable string.""" + if seconds < 60: + return f"{seconds} second{'s' if seconds != 1 else ''}" + elif seconds < 3600: + minutes = seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''}" + elif seconds < 86400: + hours = seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''}" + else: + days = seconds // 86400 + return f"{days} day{'s' if days != 1 else ''}" diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..45444a7 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,8 @@ +version: v2 +plugins: + - remote: buf.build/protocolbuffers/python:v33.5 + out: generated + - remote: buf.build/protocolbuffers/pyi:v33.5 + out: generated +inputs: + - directory: schema/proto diff --git a/generated/pyproject.toml b/generated/pyproject.toml new file mode 100644 index 0000000..4d90b02 --- /dev/null +++ b/generated/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "wynnsource-proto" +version = "0.1.0" +description = "The generated protobuf code for WynnSource. Basiclly a placeholder for editable installation." +readme = "README.md" +authors = [{ name = "FYWinds", email = "i@fywinds.com" }] +requires-python = ">=3.12" +dependencies = [ + "protobuf>=6.33.5", + "types-protobuf>=6.32.1.20251210", +] + + +[build-system] +requires = ["uv_build >= 0.10.4, <0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "wynnsource" +module-root = "" diff --git a/migration/README b/migration/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/migration/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/migration/env.py b/migration/env.py new file mode 100644 index 0000000..54e3156 --- /dev/null +++ b/migration/env.py @@ -0,0 +1,97 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +import app.core.db.models # noqa: F401 +from app.config import DB_CONFIG +from app.core.db import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata +if DB_CONFIG.postgres_dsn is None: + raise ValueError("Postgres DSN is not set in the configuration.") +config.set_main_option( + "sqlalchemy.url", DB_CONFIG.postgres_dsn.encoded_string() if DB_CONFIG.postgres_dsn is not None else "" +) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migration/script.py.mako b/migration/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migration/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migration/versions/507737e6b05e_init.py b/migration/versions/507737e6b05e_init.py new file mode 100644 index 0000000..6f2aeff --- /dev/null +++ b/migration/versions/507737e6b05e_init.py @@ -0,0 +1,51 @@ +"""init + +Revision ID: 507737e6b05e +Revises: +Create Date: 2026-02-18 18:08:31.882668 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '507737e6b05e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('permissions', sa.ARRAY(sa.String()), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tokens_token'), 'tokens', ['token'], unique=True) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('common_ips', sa.ARRAY(sa.String()), nullable=False), + sa.Column('creation_ip', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['id'], ['tokens.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + op.drop_index(op.f('ix_tokens_token'), table_name='tokens') + op.drop_table('tokens') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 9662daa..49c21cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,89 @@ [project] name = "wynnsource_server" -version = "0.0.1" -description = "The server backend for WynnSource Project" +version = "0.1.0" +description = "The server component of WynnSource - a Wynncraft Crowdsourcing Mod." readme = "README.md" -requires-python = ">=3.12, <3.13" -license = "AGPL-3.0" +requires-python = ">=3.12, <4.0" +license = "AGPL-3.0-or-later" authors = [{ name = "FYWinds", email = "i@windis.cn" }] keywords = ["wynncraft", "wynnsource", "wynnsource-server"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: GNU Affero General Public License v3", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Operating System :: OS Independent", ] dependencies = [ + "alembic>=1.18.4", "apscheduler>=3.11.0", "asyncpg>=0.30.0", "fastapi[standard]>=0.115.12", "loguru>=0.7.3", + "orjson>=3.11.7", "pydantic-settings>=2.10.1", + "redis>=7.1.1", + "scalar-fastapi>=1.6.2", "sqlalchemy>=2.0.40", + "wynnsource-proto", ] +[dependency-groups] +dev = [ + "basedpyright>=1.38.1", + "ruff>=0.15.1", +] +test = ["pytest>=8.3.5", "pytest-cov>=6.1.1", "pytest-xdist>=3.6.1"] + + +[tool.ruff.format] +line-ending = "lf" + [tool.ruff] line-length = 120 -target-version = "py38" -src = ["src"] +target-version = "py312" +src = ["app"] +exclude = ["generated"] [tool.ruff.lint] -select = ["W", "E", "F", "UP", "C4", "T10", "T20", "PYI", "PT", "Q"] -ignore = ["E402", "UP037"] +select = [ + "F", # Pyflakes + "W", # pycodestyle warnings + "E", # pycodestyle errors + "I", # isort + "UP", # pyupgrade + "ASYNC", # flake8-async + "C4", # flake8-comprehensions + "T10", # flake8-debugger + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "TID", # flake8-tidy-imports + "RUF", # Ruff-specific rules +] +ignore = [ + "E402", # module-import-not-at-top-of-file + "UP037", # quoted-annotation + "RUF001", # ambiguous-unicode-character-string + "RUF002", # ambiguous-unicode-character-docstring + "RUF003", # ambiguous-unicode-character-comment +] [tool.ruff.lint.pyupgrade] keep-runtime-typing = true +[tool.ruff.lint.isort] +known-first-party = ["app", "tests/*", "wynnsource"] + [tool.pytest.ini_options] -addopts = "-n 4 --cov=src --cov-report xml:cov.xml" +addopts = "-n 4 --cov=app --cov-report xml:cov.xml" -[dependency-groups] -dev = ["aiosqlite>=0.21.0"] -test = ["pytest>=8.3.5", "pytest-cov>=6.1.1", "pytest-xdist>=3.6.1"] +[tool.uv.workspace] +members = ["generated"] + +[tool.uv.sources] +wynnsource-proto = { workspace = true, editable = true } + +[tool.basedpyright] +typeCheckingMode = "basic" +exclude = ["generated"] diff --git a/schema b/schema new file mode 160000 index 0000000..2831e80 --- /dev/null +++ b/schema @@ -0,0 +1 @@ +Subproject commit 2831e8041dd4ec57bc136a4f98c74a0cf5d574ac diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5d6ea65b6cb5a0143432c008d824606c6c09bc4f GIT binary patch literal 16958 zcmeHNX;f5Kw!Szd#@NYPNw0LkZKI3Nm0B#CnpjYdu4gaRsx z%&4HqD9DV=b5TV>G0&6~Ma|=_TXQi8N*ws!sj|&#|0KOScDGrpF6-QV&pG$(y}$45 zea_(mMY)ilk3OQv{dwx;#T4}vMNu!4LYk-$Qta)H>m!PK^3glxPJ{FAWWdRQlL03K zP6nI|I2mv<;AFtbfRh0y15O5<3^*BZGVrfr;6y|$6?ZPhrLc@WJ1f7^<;=PCf0d~R zM(_Oj^C`waQ^H}vd@Zfhytanl7_{aJew7JIz6B-KjrczJ45BisP*_q8`H%pn-WD)T)i5#I)X4KGvr0=# zeyQpI`1rcITIzV{u^F_E{!+&mUSkFnNVwdDKOe2ft2+|$^l1@ZJH|r5$$Y4lY8cxa zU>04G$u#scqT%8Hxc|S@fv&DDs%K!}SsSC3$;TAP+^ai|gf`?^a$Kch#Vl3E^ zfLD*zLpUtMn3xaCFyAUwC>O{d@HJrDhfArA?=6}6^`d2uZGYc=?&3eaNNxJ-NB2<1 zZRApA)wOes44q&W^xYoQX>dFw68pZ1LfN?-j z*sPYk?3y+NY}td9$USJy3Io4525lKNxWW-(Vsr}np($9ItI}cd&-YrQ1JnpSx?3=RK~j5BSWn_vuy2gze{Q@czzd zq(y&;Gl#xF%8|2Z$?n4>W5Z3G4A;iSCm6=?8ByR;&t~7F=igQN<^y-?>-StAKD5-^ zE!fj<%aLV10pBlP@ob>0_rl<1e%lT$@%oHiQ2d=$GC0CCRzcC$jB{V?#qs6-BL|my zFFoM8deIRNU%wy^Uys10Yh8mq{QM3qS>;`IAuX}0u4Q`l*Qc<~ZwodCY(e0cQAh|$ z#wb06n}%MPRU$*$$(T1MM#iac7p?e1pxauXgKlefkg{a=N1l)FSh(b&{U5J-@38yY zEkT|>Uct+J-zPQ~9$x0_7PQ>YW527{f7^N5y=eKf2bOyK9duu_?V#K0cfVS=1cvD=@jVt5^&P9?f9ody+0cYTf1TG#HxU4^6pfG@8)aaYc}2v z_t|tS+-Kvhs`zAdHnyOe-GTH%79!J%k(yZutwaK4UpKN3pPUZy@Z$%0_#B8>x4ARI zZ}Y8C5?jQF$so72Sz$gKPm{LMldA)8!Ykk=>6<*U`qQhSUIA0bmiyl%;~Ecp`uK*g z+sr>j?2)`Aap^m9+-p5A!hfqF%xA;&vE}Qgk1k(lI_%-In}Bz@OF-bEL)6682}+?< zx+?i26pSdM3DQzIm89i+bzLJk-GwQSG>eK1QzQ1P!|T(85Vfrep_Pu_1N_)p;U=R44F zvGDc~XOOfFp*g1<30uBFZt$sTK|N<$+T08Qy8--K7O}$tzpjhuZA03g!#KTh$9Q`w ziyq<*LcrmJlbVamCu7lHRReho55o-&sE$uUgwIBVEcY*7^X9^b?&2>fD4F&46nSZInbg6V08ta})P zr8YFh8PODD#6W=!@*XLez7D90Qi=X}BGU->nJ^4!(0bmC!SWHb6qGx-;1}A!vyY84 zK*YAeD&#{`k`84~Je2u~F!XmKb?*@bx%(DverNIAyZF0%x+#II|Myzqh=3cbL)}R& zcnJ)QvTJBLuSb0tjmFb@46+7sIs7!3E*`{bQ4l3ZKwgl7Nvp|`3*yvBT-C^7Y+*r? z90?OS)5tPULsOZH>lOoK#D6<(O+}6)c4*dRXe)CdNr`}_Gy^h@5$rGqJy{m?=Uc%( z$w17qfv&p@39(1l7C1PNWyV6=CIu%_kDgp|t`vwdRAqL&-<@G5yjdaV>Yyx0BK*cc zPtKP5mO5zp{Ydz7FG4*1^!9b&E`FoMLeWOuVhPVS$v#Dc9j+yqwdl?=p&?QSdz-y3 zVjH0w9)`N~5@ck4RV5h^r$%8!I6&~1LYxwXQRxtvh6;j-;P2oP{AZvp&%uON2`PD2 zk-46rRSur)TNZGL3j-}u1#Y?rz6}Nrqdmc<18)Y%)5aQu0Cu^Z& zy$lKpy0b?yrr`cU3+h6(=*=^s=?uY5=ABD&p*PFyz;Ev#6$=TrDCk%v7?TMJ=19kU zYO3=|K7>P_a~{(4SQ6WDu<|>JJWcRLLy?;Z@r7{W|7A?-HIA6Dzb7VRq-ilQw6mdV z9|7kSgSPWVv?rMe7kbFJCa6m;68vXzjl__a*s$r-y{Lo18S6Y-P84?8XEheIEGA6@y%f|>UDudT1A8hYy}%ua}z2JdCMiV91=?_YVb$Py6_Bxe&^JK3QwI5V5QTJHda! zh~6R^R-FZ=);j1orDUFsWNm2#Q(pw5fW#-k-AiJ$Gt~gP|0;x4Hnhg;(V9SbYZ<*U zst{e(lDSusxmOWs6!HyqSp{2HKk?jLvL`yiU#?VA3Z48Fd6!LZk8^vz(?Ts|t;C09 z10+r68%Cwx(vfO(tOLp}8#;Nd;5WCSB3t7K1KHuT`J5m`$Zx!jYJ)9W;q$b6*RICBj)p3)0BiRFiaR$fy~)Y|~%u zOiwSooq4ekLSm1VmWk;7fy7oOT8qo{v`P~@VjNE*-xDqx)i##NU^{0rSi+3TQI1JH zRzuTQ~amT_C zvU3kY_8ve|R1A1UWe8id0VLj1-gy4cv+UnN7=v+%b(lY*Aaf!mxnp1Z?D1^uZM{_* zmsqszm4$y9i`P&c)iv$~xn+~z?vEgxTtw102az2T?%2P`KCU78`={XLe$<+P6)si0 zqKAuF`7;m4?RM!H=04IZ=RM^0)&EgkA)m_UbmQHTv>4YOZ(pObEK%Q>cKkSop@`32No?{ ziH!4UP>UoeOV0qG!zO!UE&5q3Dn|6Vm9ku)2yk9=PZWC2mQ#;FOG<;!G zN`&TtjtO&r`-DX-P?~6M%;4bAlQNl{>g5kSSrmRYx2b}KO#M=7EI@H| zJc2!Z3U+_IV(z!DtM0+_&s0u?9;Lq8y3M7vx8X5~=F0E-g`JP@+4(J%kPuI4X_^`v zA9<}Pzry-~_W>;N55^+zy?FbrRru>0%kkFho`^kl8ogXD=wSgYY9*{%HAYCWNJVI` zC`U<50*tAI`^Y1{i)(w78ReK;Ud>De^6*N@9E8^_a$oW^Y!gLv%32FDTSVy zEfxxrnyX8u`R%PZ9vFh`q$pHn9|oK3HXw zI1L5}FPyA=9AEj#c#x;>LVLXa)Yab6LCJLVBjO=JRdrS@>aqh-lDrR@ao?dj?+n^% zScB0UwtLy*@~aQseoEDS>6^ZNi_)poJEF*1<&pR)*3@i=AA2WO=d6eOQ8z4JzZjR2 zFMyq2K+Xk1WTa#vH8mgE$>cmI6o6ZF*?~WF<$8V4GQSr|9{i+Vfq{n{d$Cv|{a@*@ zD7%AGVXH4Iy2+|!UDqnr`F$PTFWIrbFk|*l>bYN?c3WDNdRujU>5W~v``}l*3%<>J z;oGzuz7^ZB>Fj5yD9%H|i3rI0`$5a)P>UtdE97XcuEB-K7_hVQ^dwfJzIXMSw{Pj{ zpLK2aoH>+r{3_MaH}J5A*1v7B+E&?YR`)*sz&xo;NW#K{qq(3?#I5oBSYx=`Go=iP{N2(sa zBOCuF9se9onkf4}iT^04KfF`F_1vBQsmB}b{ZnK@W>Iro?9zUX-Vv1QXJq4@ewV+= znMBT+K^={4458-CB42@hUSNAtGO+SdQ#r(g Gdi#H1gqv0X literal 0 HcmV?d00001 diff --git a/tests/core/test_metadata.py b/tests/core/test_metadata.py new file mode 100644 index 0000000..bdb2afb --- /dev/null +++ b/tests/core/test_metadata.py @@ -0,0 +1,14 @@ +from app.core.metadata import cached, permission + + +def test_metadata_decorator(): + @cached(expire=120) + @permission(permission="test.permission") + async def test_endpoint(): + pass + + assert hasattr(test_endpoint, "__metadata__") + meta = test_endpoint.__metadata__ + assert meta.permission == "test.permission" + assert meta.cache is not None + assert meta.cache.expire == 120 diff --git a/tests/proto/test_proto_codegen.py b/tests/proto/test_proto_codegen.py new file mode 100644 index 0000000..494df86 --- /dev/null +++ b/tests/proto/test_proto_codegen.py @@ -0,0 +1,51 @@ +from wynnsource import WynnSourceItem +from wynnsource.common.components_pb2 import ( + CraftedIdentification, + CraftedMeta, + DamageRange, + Durability, + Powder, + PowderSlot, + Requirements, +) +from wynnsource.common.enums_pb2 import ClassType, Element, Rarity +from wynnsource.item.gear_pb2 import AttackSpeed, CraftedGear, Gear, GearType, WeaponStats + + +def test_proto_codegen(): + item = WynnSourceItem( + name="The Great Fire Spear", + count=1, + level=30, + rarity=Rarity.RARITY_CRAFTED, + gear=Gear( + type=GearType.GEAR_TYPE_SPEAR, + requirements=Requirements(level=30, class_req=ClassType.CLASS_TYPE_WARRIOR, defense_req=20), + crafted=CraftedGear( + crafted_meta=CraftedMeta(author="TestCrafter"), + durability=Durability(current=232, max=235), + powders=[ + PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), + PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), + PowderSlot(), + ], + identifications=[ + CraftedIdentification(id=35, current_val=29, max_val=30), + CraftedIdentification(id=36, current_val=15, max_val=15), + ], + ), + weapon_stats=WeaponStats( + attack_speed=AttackSpeed.ATTACK_SPEED_FAST, + damages=[ + DamageRange(element=Element.ELEMENT_NEUTRAL, min=50, max=70), + DamageRange(element=Element.ELEMENT_FIRE, min=250, max=360), + ], + ), + ), + ) + + encoded = item.SerializeToString() + decoded = WynnSourceItem() + decoded.ParseFromString(encoded) + assert item == decoded + return encoded diff --git a/uv.lock b/uv.lock index f3e3ad7..09ab373 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,39 @@ version = 1 -revision = 2 -requires-python = "==3.12.*" +revision = 3 +requires-python = ">=3.12, <4.0" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[manifest] +members = [ + "wynnsource-proto", + "wynnsource-server", +] [[package]] -name = "aiosqlite" -version = "0.21.0" +name = "alembic" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -25,65 +47,100 @@ wheels = [ [[package]] name = "anyio" -version = "4.9.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] name = "apscheduler" -version = "3.11.0" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] [[package]] name = "asyncpg" -version = "0.30.0" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.38.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/ea/4d45e3c66c609496f3069a7c9e5fbd1f9ba54097c41b89048af0d8021ea6/basedpyright-1.38.1.tar.gz", hash = "sha256:e4876aa3ef7c76569ffdcd908d4e260b8d1a1deaa8838f2486f91a10b60d68d6", size = 25267403, upload-time = "2026-02-18T09:20:45.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, - { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, - { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, + { url = "https://files.pythonhosted.org/packages/28/92/42f4dc30a28c052a70c939d8dbb34102674b48c89369010442038d3c888b/basedpyright-1.38.1-py3-none-any.whl", hash = "sha256:24f21661d2754687b64f3bc35efcc78781e11b08c8b2310312ed92bf178ea627", size = 12311610, upload-time = "2026-02-18T09:20:50.09Z" }, ] [[package]] name = "certifi" -version = "2025.4.26" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -97,66 +154,133 @@ wheels = [ [[package]] name = "coverage" -version = "7.8.0" +version = "7.13.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684, upload-time = "2025-03-30T20:35:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935, upload-time = "2025-03-30T20:35:31.912Z" }, - { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994, upload-time = "2025-03-30T20:35:33.455Z" }, - { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885, upload-time = "2025-03-30T20:35:35.354Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142, upload-time = "2025-03-30T20:35:37.121Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906, upload-time = "2025-03-30T20:35:39.07Z" }, - { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124, upload-time = "2025-03-30T20:35:40.598Z" }, - { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317, upload-time = "2025-03-30T20:35:42.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170, upload-time = "2025-03-30T20:35:44.216Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969, upload-time = "2025-03-30T20:35:45.797Z" }, - { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] name = "dnspython" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] name = "email-validator" -version = "2.2.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] [[package]] name = "execnet" -version = "2.1.1" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "fastapi" -version = "0.115.12" +version = "0.129.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [package.optional-dependencies] @@ -165,44 +289,160 @@ standard = [ { name = "fastapi-cli", extra = ["standard"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] [[package]] name = "fastapi-cli" -version = "0.0.7" +version = "0.0.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/5a/500ec4deaa9a5d6bc7909cbd7b252fa37fe80d418c55a65ce5ed11c53505/fastapi_cli-0.0.21.tar.gz", hash = "sha256:457134b8f3e08d2d203a18db923a18bbc1a01d9de36fbe1fa7905c4d02a0e5c0", size = 19664, upload-time = "2026-02-11T15:27:59.65Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/d1f3ea2a1661d80c62c7b1537184ec28ec832eefb7ad1ff3047813d19452/fastapi_cli-0.0.21-py3-none-any.whl", hash = "sha256:57c6e043694c68618eee04d00b4d93213c37f5a854b369d2871a77dfeff57e91", size = 12391, upload-time = "2026-02-11T15:27:58.181Z" }, ] [package.optional-dependencies] standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] +sdist = { url = "https://files.pythonhosted.org/packages/1b/59/3def056ec8350df78a0786b7ca40a167cbf28ac26552ced4e19e1f83e872/fastapi_cloud_cli-0.12.0.tar.gz", hash = "sha256:c897d1d5e27f5b4148ed2601076785155ec8fb385a6a62d3e8801880f929629f", size = 38508, upload-time = "2026-02-13T19:39:57.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6f/badabb5a21388b0af2b9cd0c2a5d81aaecfca57bf382872890e802eaed98/fastapi_cloud_cli-0.12.0-py3-none-any.whl", hash = "sha256:9c666c2ab1684cee48a5b0a29ac1ae0bd395b9a13bf6858448b4369ea68beda1", size = 27735, upload-time = "2026-02-13T19:39:58.705Z" }, +] + +[[package]] +name = "fastar" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" }, + { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" }, + { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" }, + { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, + { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, + { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" }, + { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" }, + { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, +] [[package]] name = "greenlet" -version = "3.2.1" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/74/907bb43af91782e0366b0960af62a8ce1f9398e4291cac7beaeffbee0c04/greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7", size = 184475, upload-time = "2025-04-22T14:40:18.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/d1/e4777b188a04726f6cf69047830d37365b9191017f54caf2f7af336a6f18/greenlet-3.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea", size = 270381, upload-time = "2025-04-22T14:25:43.69Z" }, - { url = "https://files.pythonhosted.org/packages/59/e7/b5b738f5679247ddfcf2179c38945519668dced60c3164c20d55c1a7bb4a/greenlet-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8", size = 637195, upload-time = "2025-04-22T14:53:44.563Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9f/57968c88a5f6bc371364baf983a2e5549cca8f503bfef591b6dd81332cbc/greenlet-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840", size = 651381, upload-time = "2025-04-22T14:54:59.439Z" }, - { url = "https://files.pythonhosted.org/packages/40/81/1533c9a458e9f2ebccb3ae22f1463b2093b0eb448a88aac36182f1c2cd3d/greenlet-3.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9", size = 646110, upload-time = "2025-04-22T15:04:35.739Z" }, - { url = "https://files.pythonhosted.org/packages/06/66/25f7e4b1468ebe4a520757f2e41c2a36a2f49a12e963431b82e9f98df2a0/greenlet-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12", size = 648070, upload-time = "2025-04-22T14:27:05.976Z" }, - { url = "https://files.pythonhosted.org/packages/d7/4c/49d366565c4c4d29e6f666287b9e2f471a66c3a3d8d5066692e347f09e27/greenlet-3.2.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22", size = 603816, upload-time = "2025-04-22T14:25:57.224Z" }, - { url = "https://files.pythonhosted.org/packages/04/15/1612bb61506f44b6b8b6bebb6488702b1fe1432547e95dda57874303a1f5/greenlet-3.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1", size = 1119572, upload-time = "2025-04-22T14:58:58.277Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2f/002b99dacd1610e825876f5cbbe7f86740aa2a6b76816e5eca41c8457e85/greenlet-3.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145", size = 1147442, upload-time = "2025-04-22T14:28:11.243Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ba/82a2c3b9868644ee6011da742156247070f30e952f4d33f33857458450f2/greenlet-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7132e024ebeeeabbe661cf8878aac5d2e643975c4feae833142592ec2f03263d", size = 296207, upload-time = "2025-04-22T14:54:40.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, ] [[package]] @@ -229,17 +469,31 @@ wheels = [ [[package]] name = "httptools" -version = "0.6.4" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, - { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -259,20 +513,20 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -300,34 +554,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -339,27 +650,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/d0/81d98b8fddc45332f79d6ad5749b1c7409fb18723545eae75d9b7e0048fb/nodejs_wheel_binaries-24.13.1.tar.gz", hash = "sha256:512659a67449a038231e2e972d49e77049d2cf789ae27db39eff4ab1ca52ac57", size = 8056, upload-time = "2026-02-12T17:31:04.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/04/1ffe1838306654fcb50bcf46172567d50c8e27a76f4b9e55a1971fab5c4f/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:360ac9382c651de294c23c4933a02358c4e11331294983f3cf50ca1ac32666b1", size = 54757440, upload-time = "2026-02-12T17:30:35.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/f6/81ad81bc3bd919a20b110130c4fd318c7b6a5abb37eb53daa353ad908012/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:035b718946793986762cdd50deee7f5f1a8f1b0bad0f0cfd57cad5492f5ea018", size = 54932957, upload-time = "2026-02-12T17:30:40.114Z" }, + { url = "https://files.pythonhosted.org/packages/14/be/8e8a2bd50953c4c5b7e0fca07368d287917b84054dc3c93dd26a2940f0f9/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f795e9238438c4225f76fbd01e2b8e1a322116bbd0dc15a7dbd585a3ad97961e", size = 59287257, upload-time = "2026-02-12T17:30:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/92f6dfa40647702a9fa6d32393ce4595d0fc03c1daa9b245df66cc60e959/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:978328e3ad522571eb163b042dfbd7518187a13968fe372738f90fdfe8a46afc", size = 59781783, upload-time = "2026-02-12T17:30:47.387Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a5/457b984cf675cf86ace7903204b9c36edf7a2d1b4325ddf71eaf8d1027c7/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e1dc893df85299420cd2a5feea0c3f8482a719b5f7f82d5977d58718b8b78b5f", size = 61287166, upload-time = "2026-02-12T17:30:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/3c/99/da515f7bc3bce35cfa6005f0e0c4e3c4042a466782b143112eb393b663be/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0e581ae219a39073dcadd398a2eb648f0707b0f5d68c565586139f919c91cbe9", size = 61870142, upload-time = "2026-02-12T17:30:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c0/22001d2c96d8200834af7d1de5e72daa3266c7270330275104c3d9ddd143/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:d4c969ea0bcb8c8b20bc6a7b4ad2796146d820278f17d4dc20229b088c833e22", size = 41185473, upload-time = "2026-02-12T17:30:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] [[package]] name = "pydantic" -version = "2.11.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -367,256 +762,497 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540, upload-time = "2025-04-29T20:38:55.02Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900, upload-time = "2025-04-29T20:38:52.724Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, ] [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pytest" -version = "8.3.5" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-cov" -version = "6.1.1" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, + { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-xdist" -version = "3.6.1" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060, upload-time = "2024-04-28T19:29:54.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-dotenv" -version = "1.1.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, ] [[package]] name = "rich" -version = "14.0.0" +version = "14.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] [[package]] name = "rich-toolkit" -version = "0.14.4" +version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/33/18332e1359803ae6407a1e605a6bdb253a426ffe931555f1299f9e39eece/rich_toolkit-0.14.4.tar.gz", hash = "sha256:db256cf45165cae381c9bbf3b48a0fd4d99a07c80155cc655c80212a62e28fe1", size = 104487, upload-time = "2025-04-29T19:43:36.904Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/48/c6d43d4c56c45c0171c771b2b73deeec493efb57795b651319201e7c4638/rich_toolkit-0.14.4-py3-none-any.whl", hash = "sha256:cc71ebee83eaa122d8e42882408bc5a4bf0240bbf1e368811ee56d249b3d742a", size = 24258, upload-time = "2025-04-29T19:43:35.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, ] [[package]] -name = "shellingham" -version = "1.5.4" +name = "rignore" +version = "0.7.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +] + +[[package]] +name = "scalar-fastapi" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/27/4fefc485d6d3b32c59e48cc5d7ba20180dc15984b4b50cbc8a0da18670d6/scalar_fastapi-1.6.2.tar.gz", hash = "sha256:2aea052b8ebd585f8f4a71c2eeb4a7250f4dba1172aa056464ff0fe7ae49bdd5", size = 8069, upload-time = "2026-02-11T21:26:22.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/16/fafcc10900b1d330641444163adac525322628684d04dc24bd8d5ab7109a/scalar_fastapi-1.6.2-py3-none-any.whl", hash = "sha256:922ff1823a5bc5dc6b6df318a468e1a8401babc973e74ccbf79154f50de5e13f", size = 7443, upload-time = "2026-02-11T21:26:21.175Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "sqlalchemy" -version = "2.0.40" +version = "2.0.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299, upload-time = "2025-03-27T17:52:31.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/06/552c1f92e880b57d8b92ce6619bd569b25cead492389b1d84904b55989d8/sqlalchemy-2.0.40-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d", size = 2112620, upload-time = "2025-03-27T18:40:00.071Z" }, - { url = "https://files.pythonhosted.org/packages/01/72/a5bc6e76c34cebc071f758161dbe1453de8815ae6e662393910d3be6d70d/sqlalchemy-2.0.40-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a", size = 2103004, upload-time = "2025-03-27T18:40:04.204Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fd/0e96c8e6767618ed1a06e4d7a167fe13734c2f8113c4cb704443e6783038/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d", size = 3252440, upload-time = "2025-03-27T18:51:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/cd/6a/eb82e45b15a64266a2917a6833b51a334ea3c1991728fd905bfccbf5cf63/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716", size = 3263277, upload-time = "2025-03-27T18:50:28.142Z" }, - { url = "https://files.pythonhosted.org/packages/45/97/ebe41ab4530f50af99e3995ebd4e0204bf1b0dc0930f32250dde19c389fe/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2", size = 3198591, upload-time = "2025-03-27T18:51:27.543Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1c/a569c1b2b2f5ac20ba6846a1321a2bf52e9a4061001f282bf1c5528dcd69/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191", size = 3225199, upload-time = "2025-03-27T18:50:30.069Z" }, - { url = "https://files.pythonhosted.org/packages/8f/91/87cc71a6b10065ca0209d19a4bb575378abda6085e72fa0b61ffb2201b84/sqlalchemy-2.0.40-cp312-cp312-win32.whl", hash = "sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1", size = 2082959, upload-time = "2025-03-27T18:45:57.574Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/14c511cda174aa1ad9b0e42b64ff5a71db35d08b0d80dc044dae958921e5/sqlalchemy-2.0.40-cp312-cp312-win_amd64.whl", hash = "sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0", size = 2108526, upload-time = "2025-03-27T18:45:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894, upload-time = "2025-03-27T18:40:43.796Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, ] [[package]] name = "starlette" -version = "0.46.2" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] name = "typer" -version = "0.15.3" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload-time = "2025-04-28T21:40:59.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20251210" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload-time = "2025-04-28T21:40:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, ] [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] @@ -631,17 +1267,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" -version = "0.34.2" +version = "0.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] [package.optional-dependencies] @@ -657,60 +1302,149 @@ standard = [ [[package]] name = "uvloop" -version = "0.21.0" +version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "watchfiles" -version = "1.0.5" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload-time = "2025-04-08T10:35:17.956Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload-time = "2025-04-08T10:35:19.202Z" }, - { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload-time = "2025-04-08T10:35:20.586Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload-time = "2025-04-08T10:35:21.87Z" }, - { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload-time = "2025-04-08T10:35:23.143Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload-time = "2025-04-08T10:35:24.702Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload-time = "2025-04-08T10:35:25.969Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload-time = "2025-04-08T10:35:27.353Z" }, - { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload-time = "2025-04-08T10:35:28.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload-time = "2025-04-08T10:35:30.42Z" }, - { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload-time = "2025-04-08T10:35:32.023Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload-time = "2025-04-08T10:35:33.225Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload-time = "2025-04-08T10:35:34.568Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] name = "websockets" -version = "15.0.1" +version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] @@ -722,22 +1456,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "wynnsource-proto" +version = "0.1.0" +source = { editable = "generated" } +dependencies = [ + { name = "protobuf" }, + { name = "types-protobuf" }, +] + +[package.metadata] +requires-dist = [ + { name = "protobuf", specifier = ">=6.33.5" }, + { name = "types-protobuf", specifier = ">=6.32.1.20251210" }, +] + [[package]] name = "wynnsource-server" -version = "0.0.1" +version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, { name = "apscheduler" }, { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, { name = "loguru" }, + { name = "orjson" }, { name = "pydantic-settings" }, + { name = "redis" }, + { name = "scalar-fastapi" }, { name = "sqlalchemy" }, + { name = "wynnsource-proto" }, ] [package.dev-dependencies] dev = [ - { name = "aiosqlite" }, + { name = "basedpyright" }, + { name = "ruff" }, ] test = [ { name = "pytest" }, @@ -747,16 +1502,24 @@ test = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.18.4" }, { name = "apscheduler", specifier = ">=3.11.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, + { name = "redis", specifier = ">=7.1.1" }, + { name = "scalar-fastapi", specifier = ">=1.6.2" }, { name = "sqlalchemy", specifier = ">=2.0.40" }, + { name = "wynnsource-proto", editable = "generated" }, ] [package.metadata.requires-dev] -dev = [{ name = "aiosqlite", specifier = ">=0.21.0" }] +dev = [ + { name = "basedpyright", specifier = ">=1.38.1" }, + { name = "ruff", specifier = ">=0.15.1" }, +] test = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, From 05a5d20e70f5ee3edcf3b3dcb94f21f3c8b10ca0 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 18 Feb 2026 21:49:45 -0500 Subject: [PATCH 02/78] fix: streamline dev dependencies format and add diagnostic settings for type checking --- pyproject.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 49c21cd..7a63686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,7 @@ dependencies = [ ] [dependency-groups] -dev = [ - "basedpyright>=1.38.1", - "ruff>=0.15.1", -] +dev = ["basedpyright>=1.38.1", "ruff>=0.15.1"] test = ["pytest>=8.3.5", "pytest-cov>=6.1.1", "pytest-xdist>=3.6.1"] @@ -86,4 +83,6 @@ wynnsource-proto = { workspace = true, editable = true } [tool.basedpyright] typeCheckingMode = "basic" +diagnosticMode = "workspace" +include = ["app", "tests/*"] exclude = ["generated"] From bfc0369cad8e5f036d97ab48e1985a8763d8e9ec Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 18 Feb 2026 22:04:42 -0500 Subject: [PATCH 03/78] feat: added user based rate limiting --- app/core/metadata.py | 4 ++-- app/core/rate_limiter/__init__.py | 8 +++++++- app/core/rate_limiter/base.py | 15 ++++++++++++-- app/core/rate_limiter/memory_rate_limiter.py | 4 ++-- app/core/rate_limiter/redis_rate_limiter.py | 4 ++-- app/core/router.py | 21 ++++++++++++++------ app/core/security/auth.py | 2 ++ app/module/pool/router.py | 15 +++++++++++++- 8 files changed, 57 insertions(+), 16 deletions(-) diff --git a/app/core/metadata.py b/app/core/metadata.py index fec7cb6..e353bb9 100644 --- a/app/core/metadata.py +++ b/app/core/metadata.py @@ -1,7 +1,7 @@ import dataclasses from collections.abc import Awaitable, Callable -from app.core.rate_limiter.base import RateLimitKeyFunc, default_key_func +from app.core.rate_limiter import RateLimitKeyFunc, ip_based_key_func @dataclasses.dataclass @@ -72,7 +72,7 @@ def decorator(func): return decorator -def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = default_key_func): +def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func): """ Decorator to add rate limit metadata to an API endpoint. """ diff --git a/app/core/rate_limiter/__init__.py b/app/core/rate_limiter/__init__.py index 11f61c7..2317d0a 100644 --- a/app/core/rate_limiter/__init__.py +++ b/app/core/rate_limiter/__init__.py @@ -1,6 +1,9 @@ from app.config.db import DB_CONFIG -from app.core.rate_limiter.base import BaseRateLimiter as BaseRateLimiter +from .base import BaseRateLimiter as BaseRateLimiter +from .base import RateLimitKeyFunc as RateLimitKeyFunc +from .base import ip_based_key_func as ip_based_key_func +from .base import user_based_key_func as user_based_key_func from .memory_rate_limiter import MemoryRateLimiter from .redis_rate_limiter import RedisRateLimiter @@ -8,5 +11,8 @@ RateLimiter: type[BaseRateLimiter] = MemoryRateLimiter if DB_CONFIG.redis_dsn is None else RedisRateLimiter __all__ = [ + "RateLimitKeyFunc", "RateLimiter", + "ip_based_key_func", + "user_based_key_func", ] diff --git a/app/core/rate_limiter/base.py b/app/core/rate_limiter/base.py index 2229bc9..b97ba28 100644 --- a/app/core/rate_limiter/base.py +++ b/app/core/rate_limiter/base.py @@ -6,7 +6,7 @@ type RateLimitKeyFunc = Callable[[Request], str] -def default_key_func(request: Request) -> str: +def ip_based_key_func(request: Request) -> str: """ Default key function that uses the client's IP address for rate limiting. If none of the above is available, it falls back to a global limit across all clients. @@ -17,12 +17,23 @@ def default_key_func(request: Request) -> str: return f"rate_limiting:{method}:{path}:{ip}" +def user_based_key_func(request: Request) -> str: + """ + Key function that uses the authenticated user's ID for rate limiting. + If the user is not authenticated, it falls back to a global limit across all clients. + """ + user_id = getattr(request.state, "user_id", None) or "global" + path = request.url.path + method = request.method + return f"rate_limiting:{method}:{path}:{user_id}" + + class BaseRateLimiter(abc.ABC): limit: int period: int key_func: RateLimitKeyFunc - def __init__(self, limit: int, period: int, key_func: RateLimitKeyFunc = default_key_func): + def __init__(self, limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func): self.limit = limit self.period = period self.key_func = key_func diff --git a/app/core/rate_limiter/memory_rate_limiter.py b/app/core/rate_limiter/memory_rate_limiter.py index c66bbda..30aff2e 100644 --- a/app/core/rate_limiter/memory_rate_limiter.py +++ b/app/core/rate_limiter/memory_rate_limiter.py @@ -4,7 +4,7 @@ from fastapi import HTTPException, Request, Response from starlette.status import HTTP_429_TOO_MANY_REQUESTS -from .base import BaseRateLimiter, RateLimitKeyFunc, default_key_func +from .base import BaseRateLimiter, RateLimitKeyFunc, ip_based_key_func class MemoryRateLimiter(BaseRateLimiter): @@ -12,7 +12,7 @@ class MemoryRateLimiter(BaseRateLimiter): current_counts: dict[str, int] previous_counts: dict[str, int] - def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = default_key_func): + def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = ip_based_key_func): super().__init__(times, seconds, key_func) self.current_window = 0 self.current_counts = {} diff --git a/app/core/rate_limiter/redis_rate_limiter.py b/app/core/rate_limiter/redis_rate_limiter.py index 535c629..2881c65 100644 --- a/app/core/rate_limiter/redis_rate_limiter.py +++ b/app/core/rate_limiter/redis_rate_limiter.py @@ -8,7 +8,7 @@ from app.core.db.redis import RedisClient -from .base import BaseRateLimiter, RateLimitKeyFunc, default_key_func +from .base import BaseRateLimiter, RateLimitKeyFunc, ip_based_key_func LUA_SLIDING_WINDOW = """ local key_prefix = KEYS[1] @@ -59,7 +59,7 @@ def script(self) -> AsyncScript: self._script = self.redis.register_script(LUA_SLIDING_WINDOW) return self._script - def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = default_key_func): + def __init__(self, times: int, seconds: int, key_func: RateLimitKeyFunc = ip_based_key_func): super().__init__(times, seconds, key_func) async def __call__(self, request: Request, response: Response) -> Response: diff --git a/app/core/router.py b/app/core/router.py index 844d487..cf987dd 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -9,8 +9,8 @@ from app.core.cache import cached from app.core.metadata import EndpointMetadata from app.core.openapi import CACHE_DOCS, RATE_LIMIT_DOCS -from app.core.rate_limiter import RateLimiter -from app.core.security.auth import depends_permission +from app.core.rate_limiter import RateLimiter, user_based_key_func +from app.core.security.auth import depends_permission, get_user from app.schemas.constants import INJECTED_NAMESPACE from app.utils.time_utils import format_time @@ -37,6 +37,7 @@ def __init__( meta.processed = True responses = responses or {} + dependencies = list(dependencies or []) # Inject caching if meta.cache: @@ -84,15 +85,19 @@ def __init__( responses[status_code]["description"] = doc["description"] description = self.add_description( - f"⏱️ Rate Limited: `{meta.rate_limit.limit}` requests per `{format_time(meta.rate_limit.period)}`.", + f"⏱️ Rate Limited: `{meta.rate_limit.limit}` requests per `{format_time(meta.rate_limit.period)}` " + + f" based on {'user' if meta.rate_limit.key_func == user_based_key_func else 'IP'}.", description, endpoint, ) - dependencies = [ - *list(dependencies or []), + if meta.rate_limit.key_func == user_based_key_func: + self.add_dependency(Depends(get_user), dependencies) # Ensure user is loaded + + self.add_dependency( Depends(RateLimiter(meta.rate_limit.limit, meta.rate_limit.period, meta.rate_limit.key_func)), - ] + dependencies, + ) super().__init__( path, endpoint, dependencies=dependencies, responses=responses, description=description, **kwargs @@ -132,5 +137,9 @@ async def injected_endpoint(*args, **kwargs): setattr(injected_endpoint, "__signature__", sig.replace(parameters=new_params)) return injected_endpoint + def add_dependency(self, dependency: params.Depends, dependencies: list[params.Depends]) -> None: + if dependency not in dependencies: + dependencies.append(dependency) + __all__ = ["DocedAPIRoute"] diff --git a/app/core/security/auth.py b/app/core/security/auth.py index a75d234..cd6e8ad 100644 --- a/app/core/security/auth.py +++ b/app/core/security/auth.py @@ -97,6 +97,8 @@ async def get_user( await update_user_ip(session, user, x_real_ip or request.client.host) if not user: raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token") + + request.state.user_id = user.id # Store user ID in request state for later use (e.g. rate limiting) return user diff --git a/app/module/pool/router.py b/app/module/pool/router.py index baf5197..bf8250c 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -1,3 +1,16 @@ from fastapi import APIRouter -PoolRouter = APIRouter(prefix="/pool", tags=["pool"]) +from app.core import metadata +from app.core.rate_limiter import user_based_key_func +from app.core.router import DocedAPIRoute + +PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=["pool"]) + + +@PoolRouter.get("/submit", summary="Submit Pool Data") +@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) +async def submit_pool_data(): + """ + Endpoint for clients to submit pool data. + """ + return {"message": "Pool data submitted successfully"} From 867512bcc35516ff6b31edce334e2de780257d26 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 20 Feb 2026 23:08:47 -0500 Subject: [PATCH 04/78] feat: better schema --- .gitignore | 2 +- app/core/scheduler.py | 6 ++ app/main.py | 7 +- app/module/manage/router.py | 24 +++-- app/module/manage/schema.py | 36 ++----- app/module/pool/model.py | 8 +- app/module/pool/router.py | 13 ++- app/module/pool/schema.py | 11 ++ app/module/pool/service.py | 21 ++++ generated/wynnsource/__init__.py | 1 + static/favicon.ico | Bin 16958 -> 67646 bytes tests/core/test_metadata.py | 14 ++- tests/proto/test_proto_codegen.py | 23 ++++ uv.lock | 168 +++++++++++++++--------------- 14 files changed, 196 insertions(+), 138 deletions(-) create mode 100644 app/core/scheduler.py create mode 100644 generated/wynnsource/__init__.py diff --git a/.gitignore b/.gitignore index 8283bc7..a2fae46 100644 --- a/.gitignore +++ b/.gitignore @@ -205,4 +205,4 @@ generated/* !generated/wynnsource !generated/pyproject.toml generated/wynnsource/* -generated/wynnsource/__init__.py \ No newline at end of file +!generated/wynnsource/__init__.py \ No newline at end of file diff --git a/app/core/scheduler.py b/app/core/scheduler.py new file mode 100644 index 0000000..1d1b266 --- /dev/null +++ b/app/core/scheduler.py @@ -0,0 +1,6 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +SCHEDULER = AsyncIOScheduler() + + +__all__ = ["SCHEDULER"] diff --git a/app/main.py b/app/main.py index e87e67f..9a78484 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,5 @@ from contextlib import asynccontextmanager -from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.staticfiles import StaticFiles @@ -9,6 +8,7 @@ from app.config import DB_CONFIG from app.core.db import RedisClient, close_db, init_db from app.core.openapi import custom_openapi +from app.core.scheduler import SCHEDULER from app.module.api.exception_handler import ( generic_exception_handler, http_exception_handler, @@ -25,14 +25,13 @@ async def lifespan(app: FastAPI): Lifespan context manager for application. """ try: - scheduler = AsyncIOScheduler() - scheduler.start() + SCHEDULER.start() await init_db() if DB_CONFIG.redis_dsn is not None: await RedisClient.init() yield finally: - scheduler.shutdown(wait=False) + SCHEDULER.shutdown(wait=False) await close_db() if DB_CONFIG.redis_dsn is not None: await RedisClient.close() diff --git a/app/module/manage/router.py b/app/module/manage/router.py index 4a13079..c16c850 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Header +from fastapi import APIRouter, Depends, Header, Query import app.core.metadata as metadata from app.core.db import get_session @@ -28,7 +28,7 @@ async def get_tokens(inactive: bool = False, session=Depends(get_session)) -> WC List all API tokens with their permissions. """ tokens = await list_tokens(session, include_inactive=inactive) - return WCSResponse(data=[TokenInfoResponse.from_orm(token) for token in tokens]) + return WCSResponse(data=[TokenInfoResponse.model_validate(token) for token in tokens]) @ManageRouter.post("/tokens", summary="Create API Token") @@ -45,7 +45,7 @@ async def create_token(tokens: list[TokenInfo], session=Depends(get_session)) -> permissions=token_info.permissions, expires_at=token_info.expires_at, ) - created_tokens.append(TokenInfoResponse.from_orm(user.token)) + created_tokens.append(TokenInfoResponse.model_validate(user.token)) LOGGER.info(f"Created {len(created_tokens)} new API tokens: {[t.token for t in created_tokens]}") return WCSResponse(data=created_tokens) @@ -77,7 +77,7 @@ async def add_permissions_to_token( for token in tokens: token.permissions = list(set(token.permissions + permissions)) - updated_token_infos = [TokenInfoResponse.from_orm(token) for token in tokens] + updated_token_infos = [TokenInfoResponse.model_validate(token) for token in tokens] LOGGER.info(f"Added permissions {permissions} to tokens: {token_strs}") return WCSResponse(data=updated_token_infos) @@ -96,7 +96,7 @@ async def remove_permissions_from_token( for token in tokens: token.permissions = list(set(token.permissions) - set(permissions)) - updated_token_infos = [TokenInfoResponse.from_orm(token) for token in tokens] + updated_token_infos = [TokenInfoResponse.model_validate(token) for token in tokens] LOGGER.info(f"Removed permissions {permissions} from tokens: {token_strs}") return WCSResponse(data=updated_token_infos) @@ -106,7 +106,7 @@ async def get_self_token_info(user: User = Depends(get_user)) -> WCSResponse[Tok """ Get information about the API token used in the current request. """ - return WCSResponse(data=TokenInfoResponse.from_orm(user.token)) + return WCSResponse(data=TokenInfoResponse.model_validate(user.token)) @ManageRouter.get("/user/self", summary="Get Self User Info") @@ -114,22 +114,24 @@ async def get_self_user_info(user: User = Depends(get_user)) -> WCSResponse[User """ Get information about the current user, including permissions and common IPs. """ - return WCSResponse(data=UserInfoResponse.from_orm(user)) + return WCSResponse(data=UserInfoResponse.model_validate(user)) @ManageRouter.get("/user", summary="Get User Info by Token") @metadata.permission(permission="admin.tokens.read") -async def get_user_info_by_token(token: list[str], session=Depends(get_session)) -> WCSResponse[list[UserInfoResponse]]: +async def get_user_info_by_token( + token: list[str] = Query(), session=Depends(get_session) +) -> WCSResponse[list[UserInfoResponse]]: """ Get information about a user by their API token. """ tokens = hash_tokens(token) users = await get_user_by_tokens(session, tokens) - return WCSResponse(data=[UserInfoResponse.from_orm(user) for user in users]) + return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) -@ManageRouter.post("/register", summary="Register New User") +@ManageRouter.post("/user/register", summary="Register New User") @metadata.rate_limit(1, 3600) async def register_user( token: str, @@ -144,4 +146,4 @@ async def register_user( session, token_str=hashed_token, permissions=[], expires_at=None, creation_ip=x_real_ip or "unknown" ) LOGGER.info(f"Registered new user with token: {token}") - return WCSResponse(data=UserInfoResponse.from_orm(user)) + return WCSResponse(data=UserInfoResponse.model_validate(user)) diff --git a/app/module/manage/schema.py b/app/module/manage/schema.py index c99ce00..2e5f366 100644 --- a/app/module/manage/schema.py +++ b/app/module/manage/schema.py @@ -1,47 +1,25 @@ import datetime -from typing import Self -from pydantic import BaseModel - -from app.core.security.model import Token, User +from pydantic import BaseModel, ConfigDict class TokenInfo(BaseModel): + model_config = ConfigDict(from_attributes=True) + token: str permissions: list[str] expires_at: datetime.datetime | None - @classmethod - def from_orm(cls, obj: Token) -> Self: - return cls( - token=obj.token, - permissions=obj.permissions, - expires_at=obj.expires_at, - ) - class TokenInfoResponse(TokenInfo): - created_at: datetime.datetime + model_config = ConfigDict(from_attributes=True) - @classmethod - def from_orm(cls, obj: Token) -> Self: - return cls( - token=obj.token, - permissions=obj.permissions, - expires_at=obj.expires_at, - created_at=obj.created_at, - ) + created_at: datetime.datetime class UserInfoResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + token: TokenInfoResponse score: int common_ips: list[str] - - @classmethod - def from_orm(cls, obj: User) -> Self: - return cls( - token=TokenInfoResponse.from_orm(obj.token), - score=obj.score, - common_ips=obj.common_ips, - ) diff --git a/app/module/pool/model.py b/app/module/pool/model.py index 81bcb92..5ac3e23 100644 --- a/app/module/pool/model.py +++ b/app/module/pool/model.py @@ -14,11 +14,13 @@ class Pool(Base): pool_type: Mapped[str] = mapped_column(String(50), nullable=False) region: Mapped[str] = mapped_column(String(50), nullable=False) + page: Mapped[int] = mapped_column(Integer, nullable=False) rotation_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) rotation_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) consensus_data: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) confidence: Mapped[float | None] = mapped_column(Float, nullable=True) submission_count: Mapped[int] = mapped_column(Integer, default=0) @@ -30,7 +32,7 @@ class Pool(Base): cascade="save-update, merge", ) - __table_args__ = (UniqueConstraint("pool_type", "region", "rotation_start", name="uq_pool_rotation_key"),) + __table_args__ = (UniqueConstraint("pool_type", "region", "page", "rotation_start", name="uq_pool_rotation_key"),) class PoolSubmission(Base): @@ -38,7 +40,9 @@ class PoolSubmission(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - rotation_id: Mapped[int] = mapped_column(ForeignKey("pools.id", ondelete="CASCADE"), index=True, nullable=False) + rotation_id: Mapped[int | None] = mapped_column( + ForeignKey("pools.id", ondelete="CASCADE"), index=True, nullable=True + ) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) submitted_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/app/module/pool/router.py b/app/module/pool/router.py index bf8250c..af7f479 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -1,16 +1,21 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession from app.core import metadata +from app.core.db import get_session from app.core.rate_limiter import user_based_key_func from app.core.router import DocedAPIRoute +from .schema import PoolSubmissionSchema +from .service import submit_pool_data + PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=["pool"]) -@PoolRouter.get("/submit", summary="Submit Pool Data") +@PoolRouter.post("/submit", summary="Submit Pool Data") @metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) -async def submit_pool_data(): +async def handle_pool_data_submission(data: PoolSubmissionSchema, session: AsyncSession = Depends(get_session)): """ Endpoint for clients to submit pool data. """ - return {"message": "Pool data submitted successfully"} + return await submit_pool_data(session, data) diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index fab29c7..ef4b91f 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -1,4 +1,7 @@ import enum +from datetime import datetime + +from pydantic import BaseModel, Field class LootPoolType(enum.StrEnum): @@ -36,3 +39,11 @@ class RaidRegion(enum.StrEnum): LootPoolType.RAID_ASPECT: RaidRegion, LootPoolType.RAID_TOME: RaidRegion, } + + +class PoolSubmissionSchema(BaseModel): + pool_type: LootPoolType + region: str = Field(description="Region or raid for the loot pool, e.g. 'Sky', 'TNA', etc.") + page: int + client_timestamp: datetime + items: list[str] = Field(description="base64-encoded protobuf bytes for item") diff --git a/app/module/pool/service.py b/app/module/pool/service.py index e69de29..71f191d 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -0,0 +1,21 @@ +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.scheduler import SCHEDULER +from app.module.pool.schema import PoolSubmissionSchema + + +async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema): + # TODO: Implement logic to save the submitted pool data to the database and trigger any necessary processing. + pass + + +@SCHEDULER.scheduled_job( + IntervalTrigger(minutes=20), + id="compute_pool_consensus", + misfire_grace_time=60, # Allow a 1-minute grace period for missed executions + coalesce=True, # Coalesce multiple missed executions into one +) +def compute_pool_consensus(): + # TODO: Implement logic to compute consensus data for active pools based on recent submissions + pass diff --git a/generated/wynnsource/__init__.py b/generated/wynnsource/__init__.py new file mode 100644 index 0000000..df3f49e --- /dev/null +++ b/generated/wynnsource/__init__.py @@ -0,0 +1 @@ +from .wynn_source_item_pb2 import WynnSourceItem as WynnSourceItem \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico index 5d6ea65b6cb5a0143432c008d824606c6c09bc4f..73fc459c2cffd9cdadc843840e1628b99ef1af4e 100644 GIT binary patch literal 67646 zcmeFa2Ut{Dwl-Wd^H07r({sD0XF7M=2BM&X0)mPJ31%?>0-|8fIV)n$Ip-V*W`d%a z6+sb1L===DS#mVI|GRcsigwT3d%v&e`R+{j=GmO8Q>RXyv)`51+WTlUKjYu**&6=; zTBG-ypJ_hUXf%54+$B5Jet9XcKK#8#b?y6~{{J)rKaIdoBkdhx|9&ITq)8KvzP`RjPft%{XlSTu+O(<0*w|RT zm;LhjPwn4t1X{Fcp)oQtQkXYs+(=_!V5l+Vdn04Pzp47HQNwRZ8wMH!eM6026a9a` zLHH5gQB(4`p@D(6ljn{2Ug&_l{(Iuzl-S#l|4odH4F7{~6)-MRfBd7y_%-%Th`Rx0yU@Wm-+c3@h7B9KHg3}7K%*x5nN19giVcmMW>F3tGBh;m zNdEtWp^-7=gpo#1zC#)i9r61QKKR!klpnzzG&3>Lm{6Y!pX_V&|@1=+Uzmh%fZ?4PanoTuOOxl6t}2*vRNF#?4HX98l*bEgAE? zEzHdR^#=_CasSUojT-f~u&_8iaNxkQkdP1*6cj+$>YhI*{x5WI z@$~6a?A^N;ecU}@+QLj^0St|eE2#^jr~`ax3w&hoS{^7Hg1=!(o3hbQ{=)wgs`~bu zZ!|5MHB&m?_%*)^E?<51)koxaPfH7n@L@xTR-8C-0!2kdZ*VRvuRz4vOBg?OKBfjN z#l@?!sH(1h^Lb8gE)E?&g1+89Xxgkf8dDzV8yZ#_7#W>q_a+_uRgc)~Qw}uN)7MZ> zXj)Ph(5Co*G7dksAN~C=f2lDcAByj8@Y=p>W@;+@{t+?oCcj4w9Xhn~)TvV_E-rq9 zYgt)2alZ(^;nUEpO&@&QybHc+>VoEN+%R%N0HQC)pt9;M9pvZdlMW8U!^;aM&BQ(+ z9T*u^8yOp4pgiy+O?+n3yt%?(T?gr*MGG^{*I$3F`T5U({$qpXpMJl3jT>vkwo|?w zb-cuC%*c1d{clYgHS)D?-8yQ-h!It%!@^KpQt}4(XC-Ag5f%mCp;KUD-4n)b`=L$m zSuk@Q4WqWcFmBfm4Vrg`sdYCDA0L2o7p|hbvhvOM^Qbd|s5^Z6`=Xh-xv~@WjErj7 zuTn=0BKDt|m}==!Xi|EcHf?^A2R{%+7#iqn+IQ@%p&h0%5`T={+}vFA)t6s>Lj69- z+S>Zkm@#9j&z?Pt($cawxD^%^q$lG$kE;+oCg?T6D(XQPU6^bqS@E8M;8`sr5E%JjcWCc zjBijL3?=TLioYQKqM}i5t(=^cP5J{3#gFPV-!y2T^s3nLO%1j28=H3RG+%%D zH5zPVV{>Wj*s(R|&YeSf`CFU{3!fn<^fY?+8w~@?ZZNj(4;%8kJ#n`u?sn{N@VD0D zFEmh}4jP(ufssWg^dB+_rz6h4LkG{E730*Y(-=5-2+Ufwf*$=w+6A@5{Tgk95u}69 zji?tC9Z(iX4C#x{KKqFd>QQf_ZyP8%DPtqC71xbT7SD=H-~3()bxc@z zIED-ziWV)+={M?w*w^veMIMY~|HAO~y~+KE{Bm?~s5c5fzL%o2by!ROMPFY}D{Bd<})_ECXI*K+u2>8Hf>tn<;#~*Sx-Ob63gd_1Gd0e)sW5nkLj|U(rVV zu{FQ`{WY$xu8PO$pJ|#?M*jJO4}ROAL4!8Lej9oJ1jlGQvG2pU-EThs?9&egciVRD zuFsq~v-ZY~8>p`5r#;Qd$FBV$=+b)-nzZT)6URYl=P^g=cj5Q%U3d=obY3vyY`p z|Hsq1b?c@f!)Q*QKCSWe@Q@q-^|#-C+sDevD%7=O$4nPjSC~_m5_jR9VtoU{>HN-~ zxq*STG4J7?nU#ZWyN;l9?;+5)=nfNmf3$TEP`vK&U*N8z0f+ue(cF0$4BC+YxqT!5 zzr+8-C>w^V&&_Esn( zG-vE>=B!zj2?+@=YHMrNu)gB|$4|1be#;&>_3(qfr5l=4SGV&FRQxWu*T>$0GGFbJ z?f1%m;!j-vJNSP;Z48B}-2gOh=>|RXj_Bz<68n!FM_zuRx?cGwI_k>Rs~9(aJpD#9 z=ouTmq1aThyAt0 zDgWKulqpjb_BXCy*Nh)KR&Mu?jLXerT}%UcC}-2krSH zY~FqV4qf^~&$2gV??|*K?pmAfUHx7kd);e=ztDxU``Q|E)=2!GZ;v zoSYoZmCKhk$3jCtIDGKnM8@JPM5e!Z@e()VQ}7@y3umq-VMoYi)G{BVYeECd*KDQT z-BIDx&U0Qp9N)+CwX7FDuh#{CkrCg)pZg^a@I&J-bR;%HGlxOc3q8@$!~t7(9Zky;^V;?tcFK1#aAa zfcuZK5qae<@xO%Hy0`et=cA`AP%%5l0b0D3PJd6%3*KVW)yG};S}XV8r4`+0B124H z^S_GwyvNJ$^Y+{K!uQ|h{rdXAj5J_Q9o4|p1#{-FQ23XYmBGu~2ThvjMUnsiDEu!? z;vG_NDDQH;g=Gzll(9Hbgj0o4nxNUC|_QO|KG#;EvE1Czi$6qoBu5>DEx`J$p8Nt{(s*l zcuOP0Ci0nqa^oB@5C3mkbi#yLixmDWY!?byG`UtE+Gd8MZoL~WSNWT%i@UMzLWw}OV zz|EUCyOI8@MDHi2X5mI+8t$h(!Qm5U5PvTf2@jqi_HG(t5+7sP_DC)MH8mJ8g!$V4 z0sf-zmCjd~|1ZFwIpy&)iT}%2c!R&5-c9DUJ`N5I{XKnxvDdC&e|nWM0_n$&ABW$- zfs7S3mAGLRaW@hB;9nL0O^o%h{VX#hW8uq}FBH$;O-;wrjr-s>BoNj;$HU2UECN?< z#qE0!@F+7MXRjrphIU|ebq)G4=hBq;cOW0@uzic~yL>JC>-+nT>O1f9zfSgR`Cn)M z|IhhfY`u5vJ-)B6`*rqV1J;*}pT1Dtx5$6t|0endiQjzF;M279bbr!BmCoMLZKW2` zi}fbNyo|hWC;Tt{e^Zm|5nEMq+zr3@Lc`h%4fF7-H)f57Zhp<=Ns~0)yL9<=YHI2c z&!me*1%$b8RaqvU0fm87)JwuI^jD^(Ucn=&8V>^Fhzw)&# z*YWhbxQdLgkH6OMr_S)z(v40Byv1MZ7dG#pwg1exedGIok6*8_t>^bkzNq=%%^AIo zBYfMuD<%s5tT9yjpS;a_v1G>%PA2q=Ps@EiuX};=f*Mp5)!@Z*ZTw^H+O-lVQur&M zfMea@e@VVbpLUDXR7?Gp)LKgJwgqc3TC*m)ZQHh*cFfP)+uLh;_3EVwoHIw$*WLZs z4<9}Zl6%d`D}?8$MR1{hb{)J7wm#F)vhzroyN-dg`#4-?{5L-{9d)%Fk4nLR0-Dm7 zb{w#hKAw)>bu^%RuigGv_`k(oa)DZU5&Wh8L*Xwzoetyg@bi20@sEyseID^C8n)=h z8bdAqI{DwAQKM3G^Oh0Uu3b|Fi51UkkQiQoFVf*&&Anw_u0aG2H3I9vX zP`!UG6Lj;|m`Bn2j--=+rmk4AVugy~Z{4y*<+1s@|G{f?p^lo&-<`?rEcV&l7r;KB zeI99KKJC8*xvw0?#y!T)Lx<6U=(2bYx~!Q5+et&ws_#fRdyU2QOPBEI)(w;v}q zCcbx{wvI&NKn+`V!^la2D%K$}uUWHaqkVfjxVm&ibo2!o5>yt~;C^%gQm+=Fwo)7a zV7!EJLW3guiM_4JOM<)bn)r(xQ*Eq3-oM_blM&)ensT4PKOwi|LSJRyzJ1D`U&y!~ z+b>y9KZkSbN}0z+GuB7yy1eIp)mf(neoqB-v-1kzK64s+?3{+aVRPUeIR|}?PKC?L z5pWtf7*{V`LfY*Zl;r0q{5}09s2VN7Ux%em$G^W7AECaVAfM~Lqr+cqg$DYwE}-8c zGya2#=$K9)4m zMPf?gFE(b(z>qb`KeQa!wQH9qDk@5RInDn4`+rNnZdBx%GkIaGwUind#(LiAlGl@d z=FAy9$;gn~LJ3{Kz#W^=ZS{Ec3Yi7(GxO2w&Eq6vDQ%K%ZYzE zWy2cA3_haYsEsR8NB@v*^QKLj(9lrLqD6~-%~Z{E|+X~m^@9?=3`CPit zfY68*>*a7Bz7%dN_9XnQb z^5jXdcH&JJzLPxZ;K75z*rCJ?UJ&~whWh`Un8AX%bCvFAuD&B}8I|08PCHHRtC-ha zl9Pdw+}EA2b^D96)8Bk9_bg55AU*9NP9F=wtt%Iilb(jGhxGsM#v}W2D($~&=FN)H zxz`}&AJmuYI$99D{~g^wa5-Eiknac2h0BzA@Q+xFE-MzG;}rTDgXW{hjyZ5YI|CjO zv*39$04{3>!+w!3IxHIon~{F74;T!W<%7{~N;@m96ojib;BbyO(Xd|xeUXGXp zYf(|E%^OQCv&=;jzu_OXeAcE<=R8Iu$^y!OOv(aIdHvyi#)JF!Yg)Bv@r$gitc|Z; zy`)W3hn$pH+&{euDaY62{_)L7KE4gf$Mzuk=mFddK8z&Jvr9Z2#2$>hL7{kXg>`#n zCB&b9jEUA&S0XPn4e3eoc$$`q3i*uZmR3+$1efmpiT_CQx7L@_$$cI662liAFYhJC z=RAHfJP#~Jhw0PNY2F+RKDP$`XIG-n_NC}BV-9)+1;G8xEcA(-0r!ZRa9lwi5A=r9 z>OpW`F&NHkhL9!(!C_fHI4OJwRhFs#H02g)Uln= zdnNI#vSRuy`%&>Y4AmKDQIQgk%H)eEi@A)_8@Ev!mxgCIvrrmego2nNUoCpNoQ&g(F@r#{!&c;X+pOj-!f zV{_3rcoBJjHHKVT4ZjP^;C?C)y^haT*n6BI4Mfg@d)N%vFZ4y*X&!K1sm0!T!%#RA zfBU6Aa9HAlwiElKd1rsL89E3y11Se+572_Uzd+JV;4l%3n+5Jm)Lg#QEH>20-eloV)dhMdxwkf8tN#89L5bOuU!U zrsMu0tmhxJ49=4mz~e+9{4OrVz{{)P&+c_@KKh&?&$GLqnXP2MTgYU%t{;LnGy1@K zVo%BdFLV(8Uq2Kb)((Unv3FSFjkXi|qXqG|pE3{*(h=J zZHJAdY|zqxvJFKJu#5lSgf%N3e zio(oCxPD+A<3M?eGst496=tO)9EM?IBulQfWw+Wd#8 zewvB$Cxs|Wt3X+5B_2ec!<`ey@F?~M=|*bZrwUB z=8Qt+#&hT^I?^Vy>@q>cf0cbl8gS@G8-COR^gb{bofj;G)99t>vvnyt&Rztk8FSI+ zL;(6WvoAf|Cz+s*rtOoeQZYFibl0j%Qwm%%_4MvB6(J=2!ogkRk zw+|=}^jdqs-EXX#J0kqAc>h@rs*9eY`q@*IW!^{5G{$2?^!4;Ul6Zm4f&L-e{MYv1y?aT$ zY0s33mVQARC@adwz4JjRdUyfPOHxo*l#c4$r{w=!l-$q9vpd;HJQ;%Aw0m##6iYTK3jb~g#-r!) zspxTZ5_%q;%sv%ecaMR?!hT8~*i7jO`vsH1bH@XTIN+x`2M8%o`C~(VTXGzO@H>`;Jz=jKp@z@@iD9_eHJN#;GB8IoD1T z{{j?cKE(C?l>e{wzre5UuTcJsafA5F$hvzKrL>!ilb_(x)oaL3O;++>@=NpwPOy%? zS;tPD)Vc=}oBtu(zI}T&M~{YTRskpb{J zKM$V7z4xgZ=(1}x9G3T|eLnzQcZ^2QAT8FOJhm@;k0TS%Gk6lbA_C!gntox(RP+s* ziJm(r&^G8tdC-U0_ock>Qr%&R7aXY7!#nKVG& z7au_1${jtthT|FQKXX${k#IVPHd7v|inTS(HKj$!xk~&?@==)a09W@er~UU7FXfNx zQb^svYcT+^6(1+uz}@g5B!p3i(2f#YQX2iSoY>e{XUb-c>FfEiKP2{YuG8VCH5V>k z(1eGF$=jbL+__`L8s0du1*AXA%Ruslqewb?5J^$IzLW&&rXp076l$^OG4y3}lJ4G} zuyxzE6*ppHDy9BczAGj`T3Qex80kzo#&Z3@Hw{#1I{mo+kWD=gnm7JyB_-{!aIUC;hDKg7I-q| z=u6q)dvP)Cz&ZS0AO>7qqG+H`=oIujJ0Jd6sW-v{(Rte_#s~Vsda@hZ&F({Ak=U>F zgVUOUaG|Z}O8lMI42IKUe^_`Agryhb35;jA?l%N2y9`tMzf-S)D(@xyU!Gr0`>qBr z>t6FeWqZz*Q>an+r{L4K@yuH317#?rq$)&h)APfMF!Rz^qfb*rYIBl$6D5);j(ev|g#VLDpbc7-+L8xCV; z!fxa&3_P=j_WTm`C&mNLFGKgW^U-^p73MbU?BCx05~of0GmPnXl&U7_MN0P* zRS9Y_}-s_f;k+GnT@?Qo)cz@6C-RSGt7Xbltz`$rQ~cJFia^}=Zf9;F?yaVXl)^Mcdzf#^fr z2Sk%bq8F3TxxKOsJ{M^VP%rc&&kx{x;eGK7JkL@tu=|{!4{vtQDC!dFq!)J{?;*N` zG~jh%0lXp^JKR5+@}F_S>F(^k&|c{V`U;D+_M-TV_VSu?!e)dI@$ZKAu3pIIJT+Yt zUa8EygYt*h$R`yjFMLYhum(k0wEqunKm~PuHDdxL^!G&v2yZav3eKCTVjbP#2PqG> z#NLcOaOu*e%@N_@9Y>8A`OBU?dT3}%agH?YzSry9Nxn$sXeAavyl5L}nlLvnb1wg< zjn}Te!rFZeb+pje@0gRYY1Fv!ROb6PI6FB-vDUYC?%cV>n>KDd7j^c`@|64chcoZn zYRKThAAazsKmC<*y))&%VubAMEQ}pJ1{S8RG1zk)+F5kKupz^^Hjg&;C1aKHAPrWX z`k?#P$>c%uXE=Sgvy|6@PsAL!9hnRl`fv_Q`=j%w5%8oA@TDB@Pn+M1^1kPhNz5%x zBMs0OxV8cVudbk9K)Q*de7{8h@Dh)sj2C%7WIR9rx|z`WlEIWgQkYXHId>&@TR+u+`9w;BwJwHfIhgDY(%HGMe=K|poMHwGC3q1}^r0nH< zzd0T%e(QN+mh$ubi1C1{%i%}F_=~k9I^L z+z!(ZU@X9!czcoOJwzW!PLjHyd+-Ffg-oP;2vpd6N7L>IqpmqRh4e)mB5EN#f@i>G z6@ABPePA=i4ebLw=r{DGkKjrB!y6VOd&0GkA7!_8?IA7xFXsY?Cj8;^yJpFCpvZR0@hndjR5H9ffyGO@72Wg zOU|GEoLeh?X3zQndwym`fADJqJ^f#)e6i31@o&rZa-@#qAGOI7Cu%rHPaPujIGF$Y zh`GO2M~@zT$^0Mg-Ma_bGA9V)tLl~{KgHjNrKRNqUT>aC@pBL_hz~1nT%%=ua6jP>!J-=oLc$kH_?( z{`WW^sCeCj_Mq^6AD&bE1o0)kl`pvfy+}XZ4~?b2F`YVQv5GVGKfeSX)HzPe{9#QR zu$f9anCA({Wj?SN-3wiPhH$R9HpVCTOYEeA{9pdy0^{GMsK|YU7mN*S<^Ouc|B@4m zV_u-V@{lxXACCMi`1=uCp#eY2b06}# z;P1&=1F;8my5EDk#EpKR_3E*pr>^zYsFPM{o_u6#?c zsCi14NNmZEH01l*4)KvK>CHEaHpYoL)I}rFj^yq=Hl&g!EEWv8> z;^M`N!L^Xk!PyyHyAVwbxh8?JaRKMnPbdDrBQ2=82mgrwSFcliK}0S zYSmh5m$(Lltgm(c{CV6@Nue#OEoBt>{a@v*XcuB0ui3)X8haN-;BIIMdnrzDiic(M zc1qt#{J&$berV)kOaE#R{qx!IqAezJ*zXGY_xvLCJ~y{ zUiA4yzKiVlArJSZjn^k~Hgy1HgM5!Nr89FrmP0zCiEVQUG}+DsS#6PM2D)o(a1T!~Tl`+fh!?k~#~{ulc{ z4L5?fsdx$1gc_&k^rIy2-=%X`Sedm^*V(-T{T!Qa)RmT)Ib-G<8j$k~e~J$+a}9+i zga&j?;wuB`OS48qYRgYiU;Kgk;qS6PzsGprQThC5%=s=9xh-=tCXAnePVHLZ%;rGc zI<3F(M-wx8pbF@2F#x~AFWJ`;o;gIZjLsvHZz5zU3;*W;mvhDN_zx* zTYF6QUr0Nk1b1i)L~gnV8#4#e`f_#YPjm7xJzx<&ZES+i&6~k;DCgGF{`Mws_u4Za z?U=u5J-8FvjdeqhZDW*uEU{qHg(vO2?%PJ9k+liztU6)+%phFZ z^8|N-7@rF&NBog8)$bgsKw@w?bMm=ZJ#8P%=&Q8ybcEM2(h})T>;rH5556bagSl_# z6to+|d{M`4@bu>zF`=?Xj8^ZLleezI_|{BYq-|VSLEf}a)P^zL$oq$ zfo-#fqv+gr{3{_p9zA*tQ>RP?{b%|(3$SkedJGvd zgg*TM1kRZY+E;Im*S(PYZfnyXWBUc*=D|G5#!_BunWE9V!Iiw8Q?RwM18cJ2)z^zW zV~P>(ov?g@AJ#DrwP=(N`giMqR;I>aZdlQu$m9V728geAkJx|p_wXM%a-?RUpP!Qd zl>J`RD@qa34uD;&=D2fsDe{SZZp0?l3(jo8wVexK)vPJ+^{A5Tp5*)VQ|pm`b~BIN zM0(kZTL+h;oq2PFhJ>oy6dTdZ!~)v`P9uRB+~If&&z;$E7uGH97$4e$Bdi~3Wnx46 zzk%3;xmY*zAnBJlICMnA28P(TC=#&;^00qN6dHZ2kFOhl4a-(0qzhwwYtj(T)ZLxe z4&k~H^hv1C+gY08n=e0uf6p;W-`qY}hTEh&!9GFtG9`yZ7HPXyF5Ny(}xcqQt`f3t5&1El{2Durx5oN+}vM;!^NNwpQ%KrcSd-(Tsb5rxS>C^p=_i$Lil66#j_UMjj{=Jbyyz_`@{hoGW9eO#o zL2vSN%NEVCf8lr(Qby$KjwKzWom__=cCE35HmcM_4kiz{yZZ6oa*;qB5<~}(&Tkzq z!XV1LUcGu_C2fZeHk~Q^o*+4-61(Pxp?zBiY~HdNEt^^6#F|*v7LjJQB*D0GGx&6} z!>#?x5W9aldONj)xqExG>hFY3widXtZwV5ER#Fae4G{x#99?sRzK8HX<<8-9en#w% zJR^^h4u#*iKmMTLU(9`FIJ)i{jE!61YvV@f#{0OqZ9a0sMFwu+eQw3mh%E?RF&V9! z8-q2{m@$0@`K1|7u9=A^r#B)yd;{-cC4!bsLQ?2j;!T)=>6n?%%(!iHV6ZrHsxL z{Kd|1Mg9(7H&fA8eeC6R4&}k*0dDxyAOD1|ww6de&ifPWBV~Np`T0}oilM!nmF&B8 z`4aV4Yiyr?9Ct$mgJ;wcndHf0+&x->11rwJ%EB6cTx-+Swi{#PPmn~pw`oQwY2FS8 z4je$M7S=dM43mg|)YfE}8CqZ#^@8v~4zU>N*#(xK4rtxq1-@M!kV*b1h}wd!vxnp3 z4?l!Yx8aCAoW~dxeUJ5bFk>)n_pXC6dEf$E-0_e!AhJO4*V5&cy^k5IbW;2qv3@r4 z_&Xv4AD>u{49dFPh>gf49c-RG0>%b<&~MZjJLZf)K4o7XkCQRTCr$;_mw9Kma2&Vb z@S?H!#t!G~BlZZi@?Di{kYf?xMB@bU4XPRs`LCkJ z8k#oy4*tiFAJ?o}t)1tQmiA}@ZI+i}`$>+rm!mb(c&|$Dzvh1>-_LF$EpNrnxufv) zXP;o>w82V0h;1O)i~N@^G`eWC4|Thvq5)@{?znn@dBal`xOyZFR)HU%Dny`7`7jaoLhMChukNa7fs+m-@zBTLL3?m-MT zBa2uM>gj}*9`>;I>qHr8hbO1jam+Vk&)m`YoI0REBRxzRv=D)#*1)P|JLt7(&ODwY z8kp!~?x^*YEA{ZFEV#b+DP>VNc=22r!uPzVGaF`8w%cMP`7HkMN`-C0k(IFIIa(Su zp*&kodfbGp@QnyvIhA8H1`m#J;CDr4Y{pK~$0r|uj0Iyikq#?xhw;#%9+U9FpFTj~ zh;>TmEBt$mReE0hJhAWh?Ac4-s~K+72A8o4LMqjdG^)solKoIH8r1Lk!ig>EGN)V^I? zteVJJCTaG4-WM7uIJbrQ%SGgG7s}KX>Ym>}u84XmgkxaPxFL0SBg`AUfpu*a$i4Xj z!CS+)-x#6W!%>pN`p?@hu`*x_{`{vu!^zea>7<9^1YqB)qj0cuz`=tD$^TY3v7R|` z>V=3c_h6#e0&_+o^m{VGx1cb32covmr+vhA;CSzg$N3^RdMk=9@5Js!5i9@eee(zbVjqkRY3q6V~kE%5O88uHji z+FBcMYV8c_$I)0l$qx%gdSkkOANpUmXl>aNag5VSW1WD+X2l0sB5T&yZ?Zlk57AKP zYnb1+qu-#4i&$Hv?8_U(SNP|BT;+W(_h(VY-3?x)`0qQ%P!@G;Ec{1IaZPe$IYIuz04xN-6xjG7puLpx`rU(@1$G3XXrx3q!8 zmF?R)Au%!o8PRn(vFRrCXb&zJ793xHrQ7a@tVYMS z7T|n13>!Y2wC{wcq_G^1!zho=@bBe{oJ+e=a{Dk=QztM_hS8%&qnmRZWX0`eE#EO5 z-ZB?oe)c(*Dg4W6>leYN>qz{CI-!k4d)$slM^OUj&fclT`6Dq{O`g~=e>d7$Il!5= zWa^1El>M8i@ADnEc*xdx@*C?9f#9?oo3$7x@S9?%|p zkMvBA7r9P35--B}OQT%}TU*;&=H)zP zzM!sgu7PF`=Xi0gP0hvV=&u;}*_a17~6i@orA9_4)|b^G1WRmi`% z1BI7%px`3y{R@l>oTq(5J4B@l7Mx=S zYfBqM?7yV;7bU#Fj4_yd6~*vt@J9DCyMnn*l~ zE<^UUI%Hq1)%Gj3$hrO;=l9=2+gA2y(x?e-F=u2(kjH521utb@%Cb3qjW)Q-SeV## z$)W4uV9^4@`n%!T{a}iQFK^m7N6^lu9cGLUMBM2Ir2R7FlHN0JmSFaT1^D{&&k(YFs?z@tPi&yS z?})!p=bJWZK)Ym?vNbPmo1gulT`~vM+(ym+fa0KA4>0rAcj6oz}SAsOE`jx2ggu;KNMxjA=KX?C{7G! zy&LC+5bu1_gOUT30Xd|ZnFHOK|Fy@uc{_09^gT=(!JH1)Jdo$_^x`v^CJrMH9=VPM zQx#!=Vv{kxLY`kU*&l}%k5jg;_##VaYdBKgXJo1H zJ>w~d!TE$6KK|&VU)8S>9yxrN#QHSM)Bc(@{(B`}`HVil8*|Px{Cm+}*^XjfbLssM z)^ro=v{Tr+Y8uDAIqqHA%{u=RtZ6x}K9kRjl0z|Nv^Nf{pN`UdeD2cqy~rgUh|jw- za5TQ5U20)&f!3{TXrEXkI5=3*r?TtJEn&&~>&3W#r;Z)rO#AOnGJ+*Y!wb|I-zt@Ur#UPcS#=8i?91I)^Y2A*!El1?}=@j z$9p?Ny0B+_($K&NojSBZW}ArWs&u6}Bzk zuV`7xw_Egc7wsX<^-#L6QG>>Cr;b@jdYCn97IW>Zas9@1m3OzcXo>kFy^v0STV$>1 zeu@8xj6S_?2CNy2Sk3woi8D(an0dEUJ}n0E4d8gy(iYZtynsdflnK8~g3_Eu0q>W;XgIfX+7qtmXW(}ba)dq7} zvu@kk3=tf&SlaF5sI&j}*S})Nssnh&FmQg{bIO62$cwGR!wZFY5S@pKLq$G%eX?kmQFDP_|~SQ0P- z<&RHm@mKV59D)4(KmFm4@b~J1q7;6YGO&~~qU^y@B!;bpO-oY*z2>ql0vS*!9#5*rtNCb4E&H%WL>>Lh2+o&~GsM)0E^?BCsywz?kk$xYG2 z!A5n9<|Y_HJ6*=CbxUj7(>IlT*I{2sI>@1Z-L~`qZJu`MN%?c`9P6}LhxdMy9EQx% z?a2JPwS_r*OLXnr3G29ai_~aHXYCuz`I{EBcUzgaQ2AT2zqk%E{Y5kO7L4UtsyN=} zWxL5AoXdB!mi66FkbR?4>9{ib!pC-=<#BCT1CpXHL-HnqsqnNszwTUA6ys!mXy1l; zHpbVjm?v{#jAbR~Kg;}3Y0N=arlzJ27wj1iR%`Z1U7)mu%=NOKkUsy(59#xtmE&pu zql1Gz)-N7QS${%dD||2bmlMkn+AS6>TcD$BN9s8j6x^5nC#VNbDt+K+YXvK-*67Oo zeP(pQT z2Q9xKAA5=IjOo)cZQ4{E=KNF1e@RWshV>iNYgva;V#lubofu={ybIbCI{b_1i-c~A zP&#Sls+B74A=kBK^A;tO-v4R2?)>~bB+*8`n|P0LnZh@@O*vNRGLCZW+`03#qq*iG z>06q_Z{p%^DcVh94VBnDqXtYsBK28ZSStMb48_dR^KdDY#~e?fpR<^;(AG#v(c;fq z%IB=Hi6f1r3JpjT+5Yg+BgDkqWKGs>RU@S1C1PJqzFrU#9Q6AWCyr}mEeEbIps?5d z6Zy||xjR%>u{fDco_p5Xq8Z|;vqiU;iEbD9E*KKaSZ&9-;nUdrUAIJtibo+gGM zBR&Z8rgP06J9})~x(#h9BX3hDRq{JRKUx|(jtRp(RX$8|qLMF`_#%DMT0YOqpKC_^ zZFWFF1M;aWYlyDaCjq(Jx@yju<=n{^&pD@r_h0{Wxpq0`9}&&+<;&pKkv;`&N#Xf7 z_*3UEoVp59*SPP%ewEL*GH*@av;*&_18&}omD7Kx6+dO2MI7fj)JR3X_U}6fafe(d zF?sR+XZ!cxnq|wD>7dta-Mm?2VQ&8ar9FDIUd~g+ zIp?f5#z_C3N_Lk~_UrH${>{97gf^%xcn*~p8as9j?a^lFOnb-G-Wpcafn4)N<)*D$ zTj1org|vZ5L#fAULmVePox-|B<0v~^nJ3fMM)J7;%&jm!W?(Rt&-rQ3+TufeM#5F< zb(MdRVUs!uq4%_|*`s!-+xt1Q@QpJ|do6*^^8)HgE z%7-YRUnw*oHs7VAw|Q^9nJa7!ub%yJa@RQ|M`yy9&lu_1qbGBET3xE>?)9%fO=j*e zEQj@+GUtKyT~*Kdx=!WruC4wblnWimCyQANc9!%I zpO>2(o12pp^yKm5(Q&b{s^DAJ;T7Cj|E1ZnZR;EC<;A1aRL!l}*guq)m7Nj#;T#?E zeoGwNy^wZY7;$^c{~`;nGQMd+yv070CN(Fr{>$psYiOIU!$q#+B=;n8&XxLc{h~1_ zr+rvX8?c<`5`QS{;3CqtIe&`^j!o)Zq;{0+hh1W9FSU=myIL<#^rf_#vV6*(t59<% zDoaq2_gK|O)#q~^4dgve!Nr}c@GK{Tac{0S%=#*wtdMg>QKwr`|6e(~E|wNW3ZBqzI5ZfhO0`X~H5uO(-?Vbdn{oD@Ej7RF6Y4*czJ zfBlWb>)C$B^Zto7BcITR`2%H^}4U7hKRPghJ_m=iuAyAku!h%)_&<_ss^8>3o@C|V=AHYvla$K( z(F*mk)DD-Hm15hD9cXT5&b5CG;~2;PtIX-(S}+=}t*@a!rQuvq&52{jH0#%{)jUa0 zS5R--xUoLu@4SxYcXsXA@q5ap6Y@i1NA+@ZgL_vyL?2y+BKmq_*9rd?rJTf2`h9$U zi^4`QzJ2?49pz3A?VBR{jzzTFa)_HUnk5h3n|YYLBsmA|L(+iIR6_Jlh5zy6>L-FBYFqx?|UQmKgUKx{WO%atxm@L*6rJzo8?LWnz8zUy%BL_ zIkMsp;|k+>HuPPN9y_LnQ}PLJZfQ+TF)n&CPNUKC)I_DBr>uf5mO()k@Q$hJ( zz_mhY|0(|G-14&G0^N{9%l~unjB{#U&=x4++`8wSlPKTSHOYl@&Jt{`?Gdsyoc2VO z$}dV>QR3{X%WKO1CpU_*WywBOuP|%oEH#Ivl5=9ZUqX`%el5qp3?jB8+Fg? z)bpgt+oja^Yk6;fV6BUKF7PVWRELIyXeNvs_dDu>Fu9(zjHehoYZV4fT7kinS8|P1 zu4&~l5ib2W$JJ*R!lGjk5_uD6uO^ZXluUU(W9G~(*6~EKhUd>lT=(r8J`4N*v>l@F zFL8f9r{gaLg$2R7XQIn9bmiIE;#;=1YK4x>|J$~10T1eC$wNy!fByV4#*+u|rRFx* z`j$1nX>0zLGwUoLPCYI`ljLga*oTA5jhzy*Wzs|E0aFI4?b0&8^OlJxx8Jz25pKkb9Ui zd9tdp9LzN+4{bb#n~eJ;MP<+zdyMGAaaccpC*>gP>sXV#oHeN>CEA+KO0L^^CyaS2 z;{TGE)Y0Y>3KidcHRE$bh7En`<>Q@VVQIO>(AcQ6v8l;NCT1-({sRYUI@;R)jDBis z+U3glxto%PZhisiI%E~P4_|}s!`7ffKgJ9DOyl~plCv8Ly3MQ?wno{(Tl&Y zf^20u)p&fR6i;GGl})YXeBIT&yREG(M%JanD9K3V=xdv@|H=Dxq>Y+q*~q;f$~o3= z<9GGXAkTg0JW3U-Y1hVjg0eOK0PjX#~O@Yn5= zEn_U`$9g-nmMx*jITwu$_zXbS+;a{_lu0vFcaGB^9;c`KFu#`iTGT@D@9xjKoo#aSxAgLYwFd;-9E=J@wr4D)wsX(w90^zjF$;<2hpi zIoFP&H2azdMB*iFH7IXa+(pY6VD$2`L{N#M} zdF6|oI(?dP=^LtsQ)u-4R$W?$dlz$1nyam?)_pGVYu=Npw~+PQ2C>G`(%M?B>CX9g zCB5Cgv{%*v-m%@J&daypG*U&|H`1q}hAm^ul7j)&yl@Db- zUMD_Z+N3$xho=7jziF%s*36hOL*Y-KTWv#vga1q$wKMN$3$OVhX`!4xYXx=Z1M0N- zv^75^y{ltsME7Pq3ryEn*%2++84JYd#$Q;=U>ia5USj9C|O0pmFeV$rRPOUw_`E(L15Zxf} zt7!X+uRt|JyE6=tn-?K!$8tpNT*K{pL~dDy_^1=KG2ai(kYZ?at9jU@n-}yoWDN#c zpRl&NigWc#>5o)$e>Ja7dabhe>tA;n^}!j&XFZtvZ_GKeKEA#$`79v1s;>|keI3g; z9m1?-+u`Ur741D{qU-S0=sspW+{Ud(Pj1~uEWx8EPt_^KCS#nel65-W7{}Hy{;UZO z4&gJB|L1eMzmHbP_ZsSRjjZ)IY5aH<6K0%3vuMGBUtKtVz6oOq9b#|ZbmD8h*)wPU zf_gwkoQxZPTu_UE4Dc z{gKQqKYi-d?v8+YYTOQ5gZPk*xO03P*OuCe z#1p&OcOv1~4kRAm$+gDzA}O3}kwgX|^>QTF-6=pNYqHO7UV`|eteJ|B#8dKj=H*1r z{Wyv<8@cvMKG$F_t3md?1l&7&6!*`DBKb@Rk|Tq0FZ>9SP9Nr4MhB5_az7G|@8xgp zX5YnYU_Y^!>)jmUdRV8C!F5`maSaby-%!`m($X+=*f0#>`l3P;Qj51_{XX;_$@yy2 zw_(84?bP|R(PhkXxCO32@AXU2WBM}m9z7pvPqh5Mo3U+H;*v@3-ROG*M^ID$Hda2;QSQPPTKo=DW1{^X7@Y%c;gj$B1t+ zebfqGdnI*5757z3=j$47OW04c7R5yTV$LbIj(| zvzfoA@F!g_-nb9mQ`cj}g2Na-_Yjr5ES`)4-i4jnX86}5XDzC%V;o7@)`oiKI)_Hd z?bIt_oEOL(`?Z~@xgUh*slj-W5{&05L8xP$N$tI3s7X4Bn!6FGO^8Bm{6*B!@bjwDLwQDzC?*;(8LwZrn%d&4*lvGLAR};m+>8xU*w7?(F1xid>)c>4n=UiOC{O zWuowM2I=ex3NJokO_RLOM!}V*q_Z64UCc)=^Rl@YxOP``G1u`c!PE1lT+6Qu&I`bxne*@@Q@b{bj3a9|ZZnSXxvcYK`cGKrT>VUxagOm#~)O01B@^U@gya#O~RKxLw;2yK^hIyAVe^E^gm` zZujHfkqG2mpigmub9c`db1g@9;-7V{3|VI@C~K;a7Qr4~gS4AAE= z?x%5WhxANk<7aW5Adw3a=PN0%R9hJk`A!XU4zkWOV}5mfcAz(Prsjh`|M`Cl{F=-3 z86>~RIiqHbC*;XBS8!dchgXi_e&jAZICGHefO4(S2-;JpLYW^SuhN#6Cb0#^5Z~mt z^U{-92TOY_VGn9Dj^cR{YcR^L;CaOjyePkkm!+|IQNmbAVGL@Xa82pN^QcO`g1YQm zcu|muxS^+@Y7=pV%2XTAL`@_PX5ClBK}*V3$hlFyt3F*=`!^D7s# zR{t{V1(%}u@foDu4CcD42aysM#CpyUJUDql;eLI`X2yk|u#TmMvB662<2vq_S(A99 z28FS#-MRT3Iallc|Mt!XIIilt;}4St=p@jzCS*t(I|-OFolb^)xRcJb;H06QPGXqM zq$QmR(6lqqCT)R9(-49!2bYYE9WYpy@Rwu@e_{*UlJ#NP(#o=IS>Lwol~$|O?n3o?PtpM{XiH^t5tJM-6t>I)0jMho)O1(^L?%DoEi9(Ey z4qXCUa*YnOFb1&u4uU^pV7k)>r?^Q<(_ex&e2{U`B4IyuX27xi6mnt9FQvD#2K?8{ za9e@&H08lLKPEx;4uP^#=_qfMzUrVPkl(fqyJKqv4m0Ovd=fyb=ZKze?8o>E zLKWa&5)uE2F{$0PM}iHf>X#)fzuj@GZ~t`Y`k5s$RI%)+IlE{ir;%;jldM(`@X3 zo8_NYWl4L;wq0rt2-g1y`@O$cU&8M%p!(5)_I3-Ov(*s{uE0hyYUG=@kvNC9WonSQ zIe`!hT|G;9nlzN!*wE=ULaN*i~@KY7OSXa2_& z|Ir4CjetFLb%X!lC1ik`6a2xro&360`_rZR;G0r=XqyzmS?#QQM`rq(WwM<*Gu|*V z{(=;~ww4&fZBhiE`fSe2#M!?ho`P)g96tz$`KYv?JO?kYLuz(s!NCvV#{_>5`ENa7 z-*PR9e_|Zy-(g}H0%gdT!U?I%I)F?$W$XN>DQPcFeNkBlPk zb>1hj*VnW7M@JI)^IJIbo%qKT|6aG8d+RmH+xVO`6=%ZH$drbHt#TzuZcVN!{#d4n zKf>2~@CdP2M~J^WCPf?8pdbEB{HOMdzq9~9a69>$knfGXQk~<(pPat`1OB7PfnH(< z+6#65?}r;zY4BHW2l;Lz@LV3x-nsMMo}O-le}lI}p3T@R|Ga01d~e&U@~s!iE3_sZ z{B8YT>+F2-fd?kp`>)jb|5bc^3+4E0?2r}Z<<`&B(r#v-_$+?UDaBT8WnC>b;;SJ4 zcUc~s)|1kUe|84!_26Tp*cJW`o8x`h#r*o-0CJ&$7>bF$)8MabzGDoGDgO5Q@5Dc5 z@NZ(xpPm1)0gv=uD3wC|jcxFbqvPzwubyXI)qwwHne_L9AO68+8^>{PRD9H@EqNmi zJq4d~#Ubgebm1RnoOqQR(l0e{Wyj|~bH9b}wZu4W+3%eD`g*L{<`DQ#bhG|v%Rtbp zk7u}mD;|2teb*;H`MEtgIiJTC>eu%#!B=^?bg!&En>z}{^7D^pVs|9x&Ial{MIk9_rOK6s?MF$7_vLq|7~U+m@;wz+o|TX9SC~hC4;WhRGK{=JaLENXZ z#b1T}i24Q-)TteH_poMl5eMX!y1iNWiOFy7RoSoO=KA>CYrqUK|1N3F+e3{no5zux zo2zx4)KGR4c9Kuv_bAo=2xFrj2$L_3a|pl2@YI-GnYt z?f;qg7Ct{?AF3k{ZMm!Lc3)fDkFSnh9ndl0JoH)E_jRH(JoV1u!?$VN3D@>L13wTKnm{?_LHU%se+7x9{`* z@b!FM>VG2Vemg$7zoc%k_0&K7qnZ22fdw=F^+quATPNU}DVAz4Ub%ARN3a)vhw)v6 zj?<1Wr~|){2Y+uNexGmY_*Yx9`igwiiN^N74Evv3$4VHEM??NjZ2oUh_rN8wN%nmc zV2=z}8DP)-+4W!Ne+;5t0{Jcj*C)zV7s)PD?%5t?H#OjZu~c2*FO3<{#JZsnqTi$d|{8^7o$#cOyh2o zCws+>>=9nVF8=2knVE~!7QZ_y+tk=yP~gXJ#^-G{_`~fmxv2Fy3-a^t$jr#N7klyj zSvz-rHa{=#_W1i2TvPK+^&xTGgkSMrSU18dBS!nl2a>l-n$QIY+fN|3kpZmVv-sD? z^?#NB%zee5{a@~j;1Q31E~Z=zwN`<{cU}AwcXu=&z3Q7d@aa5({FI}Eqnx=Tdi$7wpkn3 ztuybFyT@?*v|n|dUC75Hk33@50=OY2CpnxjYcKJ|S~phx2-;S>)!zv3$5P)Pxg^bV z5zBH1c7z=X`-s-JX+2j$Uv9y__l9)CA)7%5wC8^Uf0h3U{4dYquj@Z&wBNP^Mf>3N zRu)Tf`f3T)p2T+4C!N>?OE&#N+E16F4+Qb&?IF+9X5_re`pv}8Zw1S2>8rUYVaCct z8?}k!_>VFF&H7JRhU)q~XT#zwu=UcKHC9G?y7lcxA2lD<+Ok?#6g|P%5YJRp`~mvoe#U^Y2dKU{(i@Zp z{D3X&lQe`J7;0oH(MUmJUVzf5=g@Zs*2QfdZ;@DY_|ZjuX!-(oC;*q>6H z!-s9Bxt%rMk6swyZwGU{4?O_veUtI|4;N?7`ftnpzVp)(C<;nV&YLEmv(9-P$IWhc z1WxYH;7@&&cqHO%nk?#PTEt-{y6Qv!7k{9&4;i6HS16<$=DYa27CkYfzH(g;Jk(EY zbL}$vpY8!>_5XLrKg#|uiND%`d)qk!*nK^6{@^yr-?)m}i&^lJ@zsZVu}^UZ2N?^) zdQxZ|5Yz}I~D)<{8v3s*Ze_ZHv>iOtoh_N7_fcz%B3fU zxd)&9msWoBn-=kbMxL)Z>7c* z_5dgT3H?8z{|)K-ul8S$k^M2SkM{ep2Z4W&UuM{YUTwR~{;mWat`+Rz4s|O2aCn2% zVGn?RK=B{JMnJ3?W5AyOi~+@;H9+wnO~`+v^I^9Df6tL>X?!Q!)Ns&yR{uBl`n=CA zU3$CLUs(E?&sgMZez!+Yy+;qwWy%?{h>fw}8d>lQ?@_*)_NCUntlm#tPip}(Ky`mx z|G#GNkFWm((Z=F7AmamK?N8#5{I~HZb_kQ(47t=IUEMNGtc5uf`0Mx zzXbk?`LDJEwfpx~jY)mZKI89KA2#bavE!@4@SGlp=U{a9M9ggp?CbZyapG83eB*0| zlTMt*N3oZ0)jhD<3|q>N%7vq^vqw9_7(ni0|5yC4jZjx~M14Q?5^i^aa|3=ukMZ-( zu>Q~J{73&&{3Fz2jDUZntCKUt*<;RWYjjca^>Ll^wuKF0o!aiBV&XM(uVE@>$&!ETT#0cy4>M_I=y_Pl0n zJIi@#End9X)MWfZYVQAP;Kx6OEx_25RSzJZaWS@`9gG1JpQc>xN$O?}qRaOZ@7eEn zi@V@02^Qfu44%dgy$KLJ8pvi zTf2V;c3?Om`Nuf(s9$qX>JGs-KJm8n7v@|>MsQRfmQewj$>1G<3omT zgfE$TX%`D}#CIwOtS{pSbmDK%f7JnJ(9a_HT2=S6<$q%SqyOpt&**9lp^fwz0U{VIe_W%a}xc)z_V?gCU9BX_3 zul65m@STUV31>(-d+LwGjyJ&HGj!V)w^>?4p*LFXR!!+Sb_eEJ?Xt2?=7Y}>px zwrynAdwBBHR}(#;{^cJ%_nbw&5Nie-`8IqB8fS=4T>U4%H!wJOSoer(C+ve?QhRVS z{+xp{Nv%!e0~(00|GEd%`Jco;q5on3>7@2%dzXaHxTNmD7UVv@rnv8YaA>fY*wsJ7 zZ}|{Br@w{k`ZtWj2Tv3g-M{(u*MD2*IXqVD>8GEzva+%)wPQW;#1o0zPyKx#dqDeI zW6SRP)TfMGPTpY1>_v9Ehg9I1#o8{NZyzzzJw_9Kiboo zv;l?j{R}qNNi#f+y0@v-(1L9#p7)J4y{)CC`A_+4;#<+}Ej{77vc6YXMfhZXKz#q% z%1U!TjFI>8X;VLSy&lkYftVPJ+!LnX@ryo;ul$m($GR41e5i7X8;J*O$bA$3{%#3Y z93zKr899T`NJ?etaL_gO(AVaB5&T+E+Y)NJX6Au=7$J6h!o;3vo$Uy*;=0Bs z4%H#xASvy^wjv558f ze)v}Z!MdHtdL6=cGzp)IbQU@i$i;|!*R_J!x(I&eD}1ho9J!ks8ydbwJmamJt1XL| zR*iLrvt&`v_(R^)AB@M>JVd*)vo)WUSrgbde!RTA`~md#N4r9u&%!rc4gu9BA4dN@tg)9%%@J>b*cwaJ&@{wR1c(jAk_n@9!T{-ss~a%km`X{ z52Sh^)dQ&>NcBLf2U0zd>VZ@bqJMn{od2QFc zcG|plyqUP6_ zf^N^h-}!Cp7D;nH517npT9%`C&6OLq6N4r3GxOWd?$*!Ts67V`K0m)5&>wLe0mo~7 zIlt}FHyww|@mgQbZ>Q;-jw8+ST3^m<-*kia&GW9`GOxX4UVHW2_L6yU-MHv0A3ArH;1Fu7~Ed=MHe4f3E-I>w($-ZzPaoL>L+A7*VEuEs2rdy&mx=NXRex z#2n_+dOiM&On&=_|6;NoAJa(*J-;2FBTEwBpWlwpu?G{^kZKI3Nm0B#CnpjYdu4gaRsx z%&4HqD9DV=b5TV>G0&6~Ma|=_TXQi8N*ws!sj|&#|0KOScDGrpF6-QV&pG$(y}$45 zea_(mMY)ilk3OQv{dwx;#T4}vMNu!4LYk-$Qta)H>m!PK^3glxPJ{FAWWdRQlL03K zP6nI|I2mv<;AFtbfRh0y15O5<3^*BZGVrfr;6y|$6?ZPhrLc@WJ1f7^<;=PCf0d~R zM(_Oj^C`waQ^H}vd@Zfhytanl7_{aJew7JIz6B-KjrczJ45BisP*_q8`H%pn-WD)T)i5#I)X4KGvr0=# zeyQpI`1rcITIzV{u^F_E{!+&mUSkFnNVwdDKOe2ft2+|$^l1@ZJH|r5$$Y4lY8cxa zU>04G$u#scqT%8Hxc|S@fv&DDs%K!}SsSC3$;TAP+^ai|gf`?^a$Kch#Vl3E^ zfLD*zLpUtMn3xaCFyAUwC>O{d@HJrDhfArA?=6}6^`d2uZGYc=?&3eaNNxJ-NB2<1 zZRApA)wOes44q&W^xYoQX>dFw68pZ1LfN?-j z*sPYk?3y+NY}td9$USJy3Io4525lKNxWW-(Vsr}np($9ItI}cd&-YrQ1JnpSx?3=RK~j5BSWn_vuy2gze{Q@czzd zq(y&;Gl#xF%8|2Z$?n4>W5Z3G4A;iSCm6=?8ByR;&t~7F=igQN<^y-?>-StAKD5-^ zE!fj<%aLV10pBlP@ob>0_rl<1e%lT$@%oHiQ2d=$GC0CCRzcC$jB{V?#qs6-BL|my zFFoM8deIRNU%wy^Uys10Yh8mq{QM3qS>;`IAuX}0u4Q`l*Qc<~ZwodCY(e0cQAh|$ z#wb06n}%MPRU$*$$(T1MM#iac7p?e1pxauXgKlefkg{a=N1l)FSh(b&{U5J-@38yY zEkT|>Uct+J-zPQ~9$x0_7PQ>YW527{f7^N5y=eKf2bOyK9duu_?V#K0cfVS=1cvD=@jVt5^&P9?f9ody+0cYTf1TG#HxU4^6pfG@8)aaYc}2v z_t|tS+-Kvhs`zAdHnyOe-GTH%79!J%k(yZutwaK4UpKN3pPUZy@Z$%0_#B8>x4ARI zZ}Y8C5?jQF$so72Sz$gKPm{LMldA)8!Ykk=>6<*U`qQhSUIA0bmiyl%;~Ecp`uK*g z+sr>j?2)`Aap^m9+-p5A!hfqF%xA;&vE}Qgk1k(lI_%-In}Bz@OF-bEL)6682}+?< zx+?i26pSdM3DQzIm89i+bzLJk-GwQSG>eK1QzQ1P!|T(85Vfrep_Pu_1N_)p;U=R44F zvGDc~XOOfFp*g1<30uBFZt$sTK|N<$+T08Qy8--K7O}$tzpjhuZA03g!#KTh$9Q`w ziyq<*LcrmJlbVamCu7lHRReho55o-&sE$uUgwIBVEcY*7^X9^b?&2>fD4F&46nSZInbg6V08ta})P zr8YFh8PODD#6W=!@*XLez7D90Qi=X}BGU->nJ^4!(0bmC!SWHb6qGx-;1}A!vyY84 zK*YAeD&#{`k`84~Je2u~F!XmKb?*@bx%(DverNIAyZF0%x+#II|Myzqh=3cbL)}R& zcnJ)QvTJBLuSb0tjmFb@46+7sIs7!3E*`{bQ4l3ZKwgl7Nvp|`3*yvBT-C^7Y+*r? z90?OS)5tPULsOZH>lOoK#D6<(O+}6)c4*dRXe)CdNr`}_Gy^h@5$rGqJy{m?=Uc%( z$w17qfv&p@39(1l7C1PNWyV6=CIu%_kDgp|t`vwdRAqL&-<@G5yjdaV>Yyx0BK*cc zPtKP5mO5zp{Ydz7FG4*1^!9b&E`FoMLeWOuVhPVS$v#Dc9j+yqwdl?=p&?QSdz-y3 zVjH0w9)`N~5@ck4RV5h^r$%8!I6&~1LYxwXQRxtvh6;j-;P2oP{AZvp&%uON2`PD2 zk-46rRSur)TNZGL3j-}u1#Y?rz6}Nrqdmc<18)Y%)5aQu0Cu^Z& zy$lKpy0b?yrr`cU3+h6(=*=^s=?uY5=ABD&p*PFyz;Ev#6$=TrDCk%v7?TMJ=19kU zYO3=|K7>P_a~{(4SQ6WDu<|>JJWcRLLy?;Z@r7{W|7A?-HIA6Dzb7VRq-ilQw6mdV z9|7kSgSPWVv?rMe7kbFJCa6m;68vXzjl__a*s$r-y{Lo18S6Y-P84?8XEheIEGA6@y%f|>UDudT1A8hYy}%ua}z2JdCMiV91=?_YVb$Py6_Bxe&^JK3QwI5V5QTJHda! zh~6R^R-FZ=);j1orDUFsWNm2#Q(pw5fW#-k-AiJ$Gt~gP|0;x4Hnhg;(V9SbYZ<*U zst{e(lDSusxmOWs6!HyqSp{2HKk?jLvL`yiU#?VA3Z48Fd6!LZk8^vz(?Ts|t;C09 z10+r68%Cwx(vfO(tOLp}8#;Nd;5WCSB3t7K1KHuT`J5m`$Zx!jYJ)9W;q$b6*RICBj)p3)0BiRFiaR$fy~)Y|~%u zOiwSooq4ekLSm1VmWk;7fy7oOT8qo{v`P~@VjNE*-xDqx)i##NU^{0rSi+3TQI1JH zRzuTQ~amT_C zvU3kY_8ve|R1A1UWe8id0VLj1-gy4cv+UnN7=v+%b(lY*Aaf!mxnp1Z?D1^uZM{_* zmsqszm4$y9i`P&c)iv$~xn+~z?vEgxTtw102az2T?%2P`KCU78`={XLe$<+P6)si0 zqKAuF`7;m4?RM!H=04IZ=RM^0)&EgkA)m_UbmQHTv>4YOZ(pObEK%Q>cKkSop@`32No?{ ziH!4UP>UoeOV0qG!zO!UE&5q3Dn|6Vm9ku)2yk9=PZWC2mQ#;FOG<;!G zN`&TtjtO&r`-DX-P?~6M%;4bAlQNl{>g5kSSrmRYx2b}KO#M=7EI@H| zJc2!Z3U+_IV(z!DtM0+_&s0u?9;Lq8y3M7vx8X5~=F0E-g`JP@+4(J%kPuI4X_^`v zA9<}Pzry-~_W>;N55^+zy?FbrRru>0%kkFho`^kl8ogXD=wSgYY9*{%HAYCWNJVI` zC`U<50*tAI`^Y1{i)(w78ReK;Ud>De^6*N@9E8^_a$oW^Y!gLv%32FDTSVy zEfxxrnyX8u`R%PZ9vFh`q$pHn9|oK3HXw zI1L5}FPyA=9AEj#c#x;>LVLXa)Yab6LCJLVBjO=JRdrS@>aqh-lDrR@ao?dj?+n^% zScB0UwtLy*@~aQseoEDS>6^ZNi_)poJEF*1<&pR)*3@i=AA2WO=d6eOQ8z4JzZjR2 zFMyq2K+Xk1WTa#vH8mgE$>cmI6o6ZF*?~WF<$8V4GQSr|9{i+Vfq{n{d$Cv|{a@*@ zD7%AGVXH4Iy2+|!UDqnr`F$PTFWIrbFk|*l>bYN?c3WDNdRujU>5W~v``}l*3%<>J z;oGzuz7^ZB>Fj5yD9%H|i3rI0`$5a)P>UtdE97XcuEB-K7_hVQ^dwfJzIXMSw{Pj{ zpLK2aoH>+r{3_MaH}J5A*1v7B+E&?YR`)*sz&xo;NW#K{qq(3?#I5oBSYx=`Go=iP{N2(sa zBOCuF9se9onkf4}iT^04KfF`F_1vBQsmB}b{ZnK@W>Iro?9zUX-Vv1QXJq4@ewV+= znMBT+K^={4458-CB42@hUSNAtGO+SdQ#r(g Gdi#H1gqv0X diff --git a/tests/core/test_metadata.py b/tests/core/test_metadata.py index bdb2afb..8df6f05 100644 --- a/tests/core/test_metadata.py +++ b/tests/core/test_metadata.py @@ -1,14 +1,22 @@ -from app.core.metadata import cached, permission +from app.core import metadata +from app.core.rate_limiter import ip_based_key_func def test_metadata_decorator(): - @cached(expire=120) - @permission(permission="test.permission") + @metadata.cached(expire=120) + @metadata.permission(permission="test.permission") + @metadata.rate_limit(limit=10, period=60) async def test_endpoint(): pass assert hasattr(test_endpoint, "__metadata__") meta = test_endpoint.__metadata__ assert meta.permission == "test.permission" + assert meta.cache is not None assert meta.cache.expire == 120 + + assert meta.rate_limit is not None + assert meta.rate_limit.limit == 10 + assert meta.rate_limit.period == 60 + assert meta.rate_limit.key_func == ip_based_key_func diff --git a/tests/proto/test_proto_codegen.py b/tests/proto/test_proto_codegen.py index 494df86..77796fa 100644 --- a/tests/proto/test_proto_codegen.py +++ b/tests/proto/test_proto_codegen.py @@ -1,3 +1,5 @@ +import base64 + from wynnsource import WynnSourceItem from wynnsource.common.components_pb2 import ( CraftedIdentification, @@ -49,3 +51,24 @@ def test_proto_codegen(): decoded.ParseFromString(encoded) assert item == decoded return encoded + + +def test_proto_decode(): + encoded_b64: str = ( + "ChdDcmFmdGVkIFNwZWFyIG9mIEZsYW1lcyAJUn0IAWJiClcKCVBsYXllcjEyMxJKQSBzcGVhciBm" + + "b3JnZWQgaW4gdGhlIGhlYXJ0IG9mIGEgdm9sY2FubywgaW1idWVkIHdpdGggdGhlIGVzc2VuY2Ugb2YgZmlyZS4aBwgBEBQ" + + "YiCeiARQIAxIGCAEQMhhkEggIBRDIARisAg==" + ) + + decoded = WynnSourceItem() + decoded.ParseFromString(base64.b64decode(encoded_b64)) + assert decoded.name == "Crafted Spear of Flames" + assert decoded.rarity == Rarity.RARITY_CRAFTED + assert decoded.WhichOneof("data") == "gear" + assert decoded.gear.type == GearType.GEAR_TYPE_SPEAR + assert decoded.gear.WhichOneof("state") == "crafted" + assert decoded.gear.crafted.crafted_meta.author == "Player123" + assert ( + decoded.gear.crafted.crafted_meta.lore + == "A spear forged in the heart of a volcano, imbued with the essence of fire." + ) diff --git a/uv.lock b/uv.lock index 09ab373..837cd2a 100644 --- a/uv.lock +++ b/uv.lock @@ -297,16 +297,16 @@ standard = [ [[package]] name = "fastapi-cli" -version = "0.0.21" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/5a/500ec4deaa9a5d6bc7909cbd7b252fa37fe80d418c55a65ce5ed11c53505/fastapi_cli-0.0.21.tar.gz", hash = "sha256:457134b8f3e08d2d203a18db923a18bbc1a01d9de36fbe1fa7905c4d02a0e5c0", size = 19664, upload-time = "2026-02-11T15:27:59.65Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/9f/cbd463e57de4e977b8ea0403f95347f9150441568b1d3fe3e4949ef80ef3/fastapi_cli-0.0.23.tar.gz", hash = "sha256:210ac280ea41e73aac5a57688781256beb23c2cba3a41266896fa43e6445c8e7", size = 19763, upload-time = "2026-02-16T19:45:53.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/cf/d1f3ea2a1661d80c62c7b1537184ec28ec832eefb7ad1ff3047813d19452/fastapi_cli-0.0.21-py3-none-any.whl", hash = "sha256:57c6e043694c68618eee04d00b4d93213c37f5a854b369d2871a77dfeff57e91", size = 12391, upload-time = "2026-02-11T15:27:58.181Z" }, + { url = "https://files.pythonhosted.org/packages/68/89/19dcfd5cd289b306abdcabac68b88a4f54b7710a2c33adc16a337ecdcdfa/fastapi_cli-0.0.23-py3-none-any.whl", hash = "sha256:7e9634fc212da0b6cfc75bd3ac366cc9dfdb43b5e9ec12e58bfd1acdd2697f25", size = 12305, upload-time = "2026-02-16T19:45:52.554Z" }, ] [package.optional-dependencies] @@ -317,7 +317,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastar" }, @@ -329,9 +329,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/59/3def056ec8350df78a0786b7ca40a167cbf28ac26552ced4e19e1f83e872/fastapi_cloud_cli-0.12.0.tar.gz", hash = "sha256:c897d1d5e27f5b4148ed2601076785155ec8fb385a6a62d3e8801880f929629f", size = 38508, upload-time = "2026-02-13T19:39:57.877Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/0b/f07f4976784978ef159fd2e8f5c16f1f9d610578fb1fd976ff1315c11ea6/fastapi_cloud_cli-0.13.0.tar.gz", hash = "sha256:4d8f42337e8021c648f6cb0672de7d5b31b0fc7387a83d7b12f974600ac3f2fd", size = 38436, upload-time = "2026-02-17T05:18:19.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/6f/badabb5a21388b0af2b9cd0c2a5d81aaecfca57bf382872890e802eaed98/fastapi_cloud_cli-0.12.0-py3-none-any.whl", hash = "sha256:9c666c2ab1684cee48a5b0a29ac1ae0bd395b9a13bf6858448b4369ea68beda1", size = 27735, upload-time = "2026-02-13T19:39:58.705Z" }, + { url = "https://files.pythonhosted.org/packages/b4/88/71a1e989d17b9edb483f32e28b7891ffdd3005271518c98ba6415987c430/fastapi_cloud_cli-0.13.0-py3-none-any.whl", hash = "sha256:874a9ed8dba34ec828f198c72de9f9a38de77ac1b15083d6bc3a4d772b0bc477", size = 27631, upload-time = "2026-02-17T05:18:18.094Z" }, ] [[package]] @@ -404,45 +404,45 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, - { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, - { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -858,16 +858,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -988,24 +988,24 @@ wheels = [ [[package]] name = "redis" -version = "7.1.1" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, + { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, ] [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] @@ -1092,27 +1092,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] @@ -1126,15 +1126,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] [[package]] @@ -1203,7 +1203,7 @@ wheels = [ [[package]] name = "typer" -version = "0.23.1" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1211,9 +1211,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" }, ] [[package]] @@ -1278,15 +1278,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [package.optional-dependencies] From d942607f87f601a36dedf4c678df37363da5ae42 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sat, 21 Feb 2026 23:42:36 -0500 Subject: [PATCH 05/78] feat: Refactor management and pool modules, add market router, and enhance user management - Added MarketRouter to the API for market-related endpoints. - Refactored ManageRouter to improve user management functionalities, including user registration, retrieval, and permission management. - Introduced new UserInfoRequest and UserInfoResponse schemas for better user data handling. - Enhanced Pool module with new submission handling and consensus computation logic. - Updated database schema to support new pool and user structures, including the addition of pool submissions. - Improved response models to include EmptyResponse for clearer API responses. - Added new enums for API tags to categorize routes effectively. --- app/config/log.py | 2 +- app/core/db/__init__.py | 6 +- app/core/db/base.py | 11 ++ app/core/db/models.py | 7 +- app/core/db/session.py | 25 ++- app/core/openapi.py | 3 +- app/core/router.py | 20 ++- app/core/score.py | 100 +++++++++++ app/core/security/auth.py | 86 +++++---- app/core/security/model.py | 155 ++++++---------- app/module/api/router.py | 2 + app/module/manage/router.py | 215 ++++++++++++----------- app/module/manage/schema.py | 25 +-- app/module/market/router.py | 6 + app/module/pool/config.py | 73 ++++++-- app/module/pool/model.py | 143 +++++++++++++-- app/module/pool/router.py | 54 +++++- app/module/pool/schema.py | 34 +++- app/module/pool/service.py | 184 ++++++++++++++++++- app/schemas/enums/__init__.py | 2 + app/schemas/enums/tag.py | 7 + app/schemas/response/__init__.py | 4 +- app/schemas/response/default_response.py | 4 +- app/schemas/response/response.py | 16 +- migration/versions/507737e6b05e_init.py | 51 ------ migration/versions/938cca31bfed_init.py | 80 +++++++++ 26 files changed, 931 insertions(+), 384 deletions(-) create mode 100644 app/core/score.py create mode 100644 app/module/market/router.py create mode 100644 app/schemas/enums/tag.py delete mode 100644 migration/versions/507737e6b05e_init.py create mode 100644 migration/versions/938cca31bfed_init.py diff --git a/app/config/log.py b/app/config/log.py index bd2e58e..763d3d8 100644 --- a/app/config/log.py +++ b/app/config/log.py @@ -6,7 +6,7 @@ class LoggerConfig(BaseSettings): Logger configuration. """ - level: str = "INFO" + level: str = "DEBUG" format: str = "{time:YY-MM-DD HH:mm:ss} [{level: <8}] {name}:{line} | {message}" diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py index cf5e05f..b773634 100644 --- a/app/core/db/__init__.py +++ b/app/core/db/__init__.py @@ -1,10 +1,12 @@ -from .base import Base +from .base import Base, BaseRepository from .redis import RedisClient -from .session import close_db, get_engine, get_session, init_db +from .session import SessionDep, close_db, get_engine, get_session, init_db __all__ = [ "Base", + "BaseRepository", "RedisClient", + "SessionDep", "close_db", "get_engine", "get_session", diff --git a/app/core/db/base.py b/app/core/db/base.py index 8d02b4a..59285b1 100644 --- a/app/core/db/base.py +++ b/app/core/db/base.py @@ -1,7 +1,18 @@ +from typing import dataclass_transform + +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import DeclarativeBase +@dataclass_transform() class Base(DeclarativeBase): """ Base class for all SQLAlchemy models. """ + + +class BaseRepository: + session: AsyncSession + + def __init__(self, session: AsyncSession): + self.session = session diff --git a/app/core/db/models.py b/app/core/db/models.py index 9e51514..195de08 100644 --- a/app/core/db/models.py +++ b/app/core/db/models.py @@ -1 +1,6 @@ -import app.core.security.model # noqa: F401 +"""This is a file for alembic to load all models for autogeneration. It should not be imported anywhere else.""" + +import app.core.security.model +import app.module.pool.model # noqa: F401 + +__all__ = [] diff --git a/app/core/db/session.py b/app/core/db/session.py index 746755d..954c40f 100644 --- a/app/core/db/session.py +++ b/app/core/db/session.py @@ -1,5 +1,8 @@ from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Annotated +from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from app.config import DB_CONFIG @@ -49,14 +52,28 @@ async def close_db(): LOGGER.debug("Database engine connection closed.") -async def get_session() -> AsyncGenerator[AsyncSession, None]: - """ - Returns the SQLAlchemy session maker. - """ +@asynccontextmanager +async def get_session(): async with session_maker() as session: try: + LOGGER.debug("Database session started.") yield session await session.commit() + LOGGER.debug("Database session committed.") except Exception: + LOGGER.exception("Error occurred during database session, rolling back.") await session.rollback() raise + + +async def get_session_fastapi() -> AsyncGenerator[AsyncSession, None]: + """ + Returns the SQLAlchemy session maker. + """ + async with get_session() as session: + yield session + + +SessionDep = Annotated[AsyncSession, Depends(get_session_fastapi)] + +__all__ = ["SessionDep", "close_db", "get_engine", "get_session", "init_db"] diff --git a/app/core/openapi.py b/app/core/openapi.py index 28314ff..90a18ea 100644 --- a/app/core/openapi.py +++ b/app/core/openapi.py @@ -2,6 +2,7 @@ from fastapi.openapi.utils import get_openapi from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ +from app.schemas.enums import ApiTag def custom_openapi(app: FastAPI): @@ -19,7 +20,7 @@ def openapi(): routes=app.routes, ) - openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": ["management", "pool"]}] + openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": [t.value for t in ApiTag]}] app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/app/core/router.py b/app/core/router.py index cf987dd..30647a2 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -1,5 +1,6 @@ import inspect from collections.abc import Callable, Sequence +from enum import Enum from functools import wraps from typing import Any @@ -24,13 +25,22 @@ def __init__( dependencies: Sequence[params.Depends] | None = None, responses: dict[int | str, dict[str, Any]] | None = None, description: str | None = None, + tags: list[str | Enum] | None = None, **kwargs, ): + + # meta related processing meta: EndpointMetadata = getattr(endpoint, "__metadata__", EndpointMetadata()) if meta.processed: super().__init__( - path, endpoint, dependencies=dependencies, responses=responses, description=description, **kwargs + path, + endpoint, + dependencies=dependencies, + responses=responses, + description=description, + tags=tags, + **kwargs, ) return @@ -100,7 +110,13 @@ def __init__( ) super().__init__( - path, endpoint, dependencies=dependencies, responses=responses, description=description, **kwargs + path, + endpoint, + dependencies=dependencies, + responses=responses, + description=description, + tags=tags, + **kwargs, ) def add_description(self, text: str, description: str | None, endpoint: Callable[..., Any]) -> str: diff --git a/app/core/score.py b/app/core/score.py new file mode 100644 index 0000000..f6ea277 --- /dev/null +++ b/app/core/score.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass +from enum import Enum + +from apscheduler.triggers.cron import CronTrigger + +from app.core.scheduler import SCHEDULER + + +@dataclass +class ScoreRange: + min: int + max: int + + +class Tier(Enum): + Infamous = ( + -10000, + -9501, + 100, + "The absolute enemy of WynnSource, universally recognized for unparalleled toxicity.", + ) + Nemesis = ( + -9500, + -7001, + 75, + "A master of disruption, carrying a dark legacy of hostility toward the community.", + ) + Corruptor = ( + -7000, + -3501, + 60, + "A commander of chaos, systematically polluting the WynnSource database.", + ) + Defiler = (-3500, -1501, 45, "A major detriment to the community, leaving a trail of harmful contributions.") + Saboteur = (-1500, -601, 30, "A notorious contributor who actively diminishes the quality of WynnSource.") + Vandal = (-600, -201, 20, "A disruptive member known for repeatedly ignoring submission guidelines.") + Outcast = (-200, -51, 10, "A user whose frequent low-quality submissions have alienated the community.") + Troublemaker = (-50, -1, 10, "A newcomer who has stumbled by submitting unhelpful content.") + + Rookie = (0, 50, 20, "The newcomer to the WynnSource community.") + Assistant = (51, 200, 15, "A regular contributor who has shown dedication.") + Sentinel = (201, 600, 12, "A vigilant guardian of quality, standing watch over the integrity of our data.") + Elite = (601, 1500, 9, "A battle-tested veteran whose submissions are considered the gold standard.") + Admiral = (1501, 3500, 6, "A visionary leader, safely navigating the community through oceans of information.") + Commander = (3501, 7000, 4, "A seasoned tactician whose exceptional directives steer the course of WynnSource.") + Master = (7001, 9500, 2, "A legendary scholar whose vast wisdom forms the very foundation of the community.") + Grandmaster = ( + 9501, + 10000, + 2, + "A living myth; their unparalleled legacy will echo through WynnSource forever.", + ) + + def __init__(self, min_score: int, max_score: int, daily_base: int, description: str): + self.score_range = ScoreRange(min_score, max_score) + self.daily_base = daily_base + self.description = description + + @classmethod + def get_by_score(cls, score: int) -> "Tier": + for tier in cls: + if tier.score_range.min <= score <= tier.score_range.max: + return tier + raise ValueError(f"Score {score} is out of bounds for all tiers.") + + def next(self) -> "Tier | None": + members = list(Tier) + index = members.index(self) + if index < len(members) - 1: + return members[index + 1] + return None + + def previous(self) -> "Tier | None": + members = list(Tier) + index = members.index(self) + if index > 0: + return members[index - 1] + return None + + def score_to_next_tier(self, score: int) -> int: + next_tier = self.next() + if next_tier is None: + return 0 # Already at max tier + return next_tier.score_range.min - score + + +@SCHEDULER.scheduled_job( + CronTrigger(hour=0, minute=0), # Run daily at midnight + id="update_user_scores", + misfire_grace_time=60, + coalesce=True, +) +async def update_user_scores(): + pass + + +async def calculate_quality_factor(): ... + + +async def calculate_activity_factor(): ... diff --git a/app/core/security/auth.py b/app/core/security/auth.py index cd6e8ad..45946e2 100644 --- a/app/core/security/auth.py +++ b/app/core/security/auth.py @@ -6,14 +6,13 @@ from fastapi import Depends, Header, HTTPException, Request, Security from fastapi.security import APIKeyHeader -from sqlalchemy.ext.asyncio import AsyncSession from starlette.status import HTTP_401_UNAUTHORIZED from app.config import ADMIN_CONFIG -from app.core.db import get_session +from app.core.db import SessionDep from app.core.log import LOGGER -from .model import Token, User, get_user_by_token, update_user_ip +from .model import User, UserRepository @lru_cache(maxsize=256) @@ -45,19 +44,8 @@ def has_permission(required: str | set[str] | None, user: set[str]) -> bool: return True -def check_permissions(token: Token, required_permissions: set[str] | None) -> None: - """ - Check if the token's permissions satisfy the required permissions. - Raises HTTPException if permissions are insufficient. - """ - user_permissions = set(token.permissions) - if not has_permission(required_permissions, user_permissions): - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Insufficient permissions") - - -async def verify_token(user: User): - token = user.token - if token.expires_at and token.expires_at < datetime.now(UTC): +async def verify_user(user: User): + if user.expires_at and user.expires_at < datetime.now(UTC): raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired") @@ -66,47 +54,60 @@ async def verify_token(user: User): async def get_user( request: Request, + session: SessionDep, + api_key: Annotated[str | None, Security(api_key_scheme)] = None, x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, - api_key: str = Security(api_key_scheme), - session: AsyncSession = Depends(get_session), ) -> User: if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None: # Create a dummy token with all permissions for the admin token LOGGER.info("Admin token used, granting all permissions") return User( - token=Token( - token="", - permissions=["*"], - created_at=datetime.now(UTC), - expires_at=datetime.now(UTC), - ), - score=0, + id=-1, + token="", + permissions=["*"], + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC), + is_active=True, + creation_ip="unknown", common_ips=[], + score=0, ) else: + if api_key is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="missing API key") + + userRepo = UserRepository(session) api_key = hash_token(api_key) - user = await get_user_by_token(session, api_key) + user = await userRepo.get_user_by_token(api_key) + + if not user: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token") assert request.client is not None if request.client.host == "127.0.0.1": - LOGGER.warning(f"Token {user.token.token} used from localhost without X-Real-IP header") + LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header") elif x_real_ip is None: - LOGGER.warning(f"Token {user.token.token} used without X-Real-IP header") + LOGGER.warning(f"Token {user.token} used without X-Real-IP header") raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address") - await update_user_ip(session, user, x_real_ip or request.client.host) - if not user: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token") + + await userRepo.update_user_ip(user, x_real_ip or request.client.host) + + await verify_user(user) request.state.user_id = user.id # Store user ID in request state for later use (e.g. rate limiting) return user +UserDep = Annotated[User, Depends(get_user)] +IpDep = Annotated[str, Header(alias="X-Real-IP", include_in_schema=False)] + + def depends_permission(permission: str | set[str]): - async def dependency(request: Request, user: User = Depends(get_user), session=Depends(get_session)) -> User: - if not has_permission(permission, set(user.token.permissions)): - LOGGER.warning(f"User {user.token.token} does not have required permissions: {permission}") - LOGGER.debug(f"User {user.token.token} permissions: {user.token.permissions}") + async def dependency(user: UserDep) -> User: + if not has_permission(permission, set(user.permissions)): + LOGGER.debug(f"User {user.token} does not have required permissions: {permission}") + LOGGER.debug(f"User {user.token} permissions: {user.permissions}") raise HTTPException(status_code=403, detail="Insufficient permissions") return user @@ -133,18 +134,13 @@ async def dependency(request: Request, user: User = Depends(get_user), session=D # return user -async def get_token(token_str: str, session=Depends(get_session)) -> Token: - LOGGER.debug(f"Verifying token: {token_str}") - user = await get_user_by_token(session, token_str) - await verify_token(user) - return user.token - - +@lru_cache(maxsize=1024) def hash_token(token_str: str) -> str: - if token_str is None: - return None return sha256(token_str.encode()).hexdigest() def hash_tokens(token_strs: list[str]) -> list[str]: - return [sha256(t.encode()).hexdigest() for t in token_strs] + return [hash_token(t) for t in token_strs] + + +__all__ = ["UserDep", "depends_permission", "get_user", "hash_token", "hash_tokens"] diff --git a/app/core/security/model.py b/app/core/security/model.py index bd24a3c..d17b996 100644 --- a/app/core/security/model.py +++ b/app/core/security/model.py @@ -1,119 +1,72 @@ -from datetime import UTC, datetime +from datetime import datetime -from fastapi import HTTPException -from sqlalchemy import DateTime, String, func, select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import DateTime, String, delete, func, select from sqlalchemy.orm import ( Mapped, - joinedload, mapped_column, - relationship, ) -from sqlalchemy.schema import ForeignKey from sqlalchemy.types import ARRAY -from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED from app.config import USER_CONFIG -from app.core.db import Base -from app.core.log import LOGGER +from app.core.db import Base, BaseRepository -class Token(Base): - __tablename__ = "tokens" +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True) + permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) is_active: Mapped[bool] = mapped_column(default=True) - permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) - user: Mapped["User"] = relationship(back_populates="token", uselist=False, cascade="all, delete-orphan") - - -class User(Base): - __tablename__ = "users" - id: Mapped[int] = mapped_column(ForeignKey("tokens.id"), primary_key=True) - score: Mapped[int] = mapped_column(default=0) + creation_ip: Mapped[str] = mapped_column(String, nullable=False) # A list of common IPs used by this user, ordered from oldest to newest. common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) - creation_ip: Mapped[str] = mapped_column(String, nullable=False) - token: Mapped["Token"] = relationship(back_populates="user", single_parent=True) - - -async def get_user_by_token(session: AsyncSession, token_str: str) -> User: - user = await session.execute( - select(User) - .join(Token) - .where(Token.token == token_str, Token.is_active == True) # noqa: E712 - .options(joinedload(User.token)) - ) - user = user.scalars().first() - if user is None: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key") - return user - - -async def get_user_by_tokens(session: AsyncSession, token_strs: list[str]) -> list[User]: - users = await session.execute( - select(User) - .join(Token) - .where(Token.token.in_(token_strs), Token.is_active == True) # noqa: E712 - .options(joinedload(User.token)) - ) - return list(users.scalars().all()) - - -async def list_tokens( - session: AsyncSession, token_str: list[str] | None = None, include_inactive: bool = False -) -> list[Token]: - query = select(Token) - if token_str is not None: - query = query.where(Token.token.in_(token_str)) - if not include_inactive: - query = query.where(Token.is_active == True) # noqa: E712 - - result = await session.execute(query) - return list(result.scalars().all()) - - -async def try_create_user( - session: AsyncSession, - token_str: str, - permissions: list[str], - expires_at: datetime | None, - creation_ip: str = "unknown", -) -> User: - existing = await session.execute(select(Token).where(Token.token == token_str, Token.is_active == True)) # noqa: E712 - if existing.scalars().first(): - raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Token already exists") - - new_token = Token(token=token_str, permissions=permissions, expires_at=expires_at, created_at=datetime.now(UTC)) - new_user = User(token=new_token, creation_ip=creation_ip) - new_token.user = new_user - - session.add(new_token) - session.add(new_user) - await session.commit() - await session.refresh(new_user, attribute_names=["token"]) - return new_user - - -async def try_delete_token(session: AsyncSession, token_str: list[str]) -> None: - LOGGER.info(f"Attempting to delete tokens: {token_str}") - token = await session.execute(select(Token).where(Token.token.in_(token_str))) - tokens = token.scalars().all() - if tokens: - for token in tokens: - await session.delete(token) - - # If no tokens were found, consider it a successful deletion - - -async def update_user_ip(session: AsyncSession, user: User, ip: str): - if ip not in user.common_ips: - if len(user.common_ips) >= USER_CONFIG.max_ip_records: - user.common_ips.pop(0) - user.common_ips.append(ip) - else: - user.common_ips.remove(ip) - user.common_ips.append(ip) + + score: Mapped[int] = mapped_column(default=0) + + +class UserRepository(BaseRepository): + async def get_user_by_token(self, token_str: str, include_inactive: bool = False) -> User | None: + query = select(User).where(User.token == token_str) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return result.scalars().first() + + async def get_users_by_tokens(self, token_strs: list[str], include_inactive: bool = False) -> list[User]: + query = select(User).where(User.token.in_(token_strs)) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def list_users(self, include_inactive: bool = False) -> list[User]: + query = select(User) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def save(self, user: User) -> None: + self.session.add(user) + await self.session.flush() + await self.session.refresh(user, attribute_names=["token"]) + + async def delete(self, token_strs: list[str]) -> None: + await self.session.execute(delete(User).where(User.token.in_(token_strs))) + await self.session.flush() + + async def update_user_ip(self, user: User, ip: str) -> None: + if ip not in user.common_ips: + if len(user.common_ips) >= USER_CONFIG.max_ip_records: + user.common_ips.pop(0) + user.common_ips.append(ip) + else: + user.common_ips.remove(ip) + user.common_ips.append(ip) + await self.session.flush() diff --git a/app/module/api/router.py b/app/module/api/router.py index 4b20221..9156ea4 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -4,12 +4,14 @@ from app.core.router import DocedAPIRoute from app.module.api.schema import ValidationErrorResponse from app.module.manage.router import ManageRouter +from app.module.market.router import MarketRouter from app.module.pool.router import PoolRouter from app.schemas.response import StatusResponse Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) Router.include_router(ManageRouter) Router.include_router(PoolRouter) +Router.include_router(MarketRouter) @Router.get("/test") diff --git a/app/module/manage/router.py b/app/module/manage/router.py index c16c850..5445dc4 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -1,149 +1,152 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, Header, Query +from fastapi import APIRouter, Query import app.core.metadata as metadata -from app.core.db import get_session +from app.core.db import SessionDep from app.core.log import LOGGER from app.core.router import DocedAPIRoute -from app.core.security.auth import get_user, hash_token, hash_tokens -from app.core.security.model import ( - User, - get_user_by_tokens, - list_tokens, - try_create_user, - try_delete_token, -) +from app.core.security.auth import IpDep, UserDep, hash_token, hash_tokens +from app.core.security.model import User, UserRepository +from app.schemas.enums import ApiTag from app.schemas.response import EMPTY_RESPONSE, WCSResponse -from .schema import TokenInfo, TokenInfoResponse, UserInfoResponse +from .schema import UserInfoRequest, UserInfoResponse -ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=["management"]) +ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=[ApiTag.MANAGEMENT]) -@ManageRouter.get("/tokens", summary="List API Tokens") -@metadata.permission("admin.tokens.read") -async def get_tokens(inactive: bool = False, session=Depends(get_session)) -> WCSResponse[list[TokenInfoResponse]]: +@ManageRouter.get("/user", summary="Get User Info by Token") +@metadata.permission(permission="admin.users.read") +async def get_user_info_by_token( + session: SessionDep, + token: list[str] = Query(), +) -> WCSResponse[list[UserInfoResponse]]: """ - List all API tokens with their permissions. + Get information about a user by their token. """ - tokens = await list_tokens(session, include_inactive=inactive) - return WCSResponse(data=[TokenInfoResponse.model_validate(token) for token in tokens]) + userRepo = UserRepository(session) + tokens = hash_tokens(token) + + users = await userRepo.get_users_by_tokens(tokens) + return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) -@ManageRouter.post("/tokens", summary="Create API Token") -@metadata.permission(permission="admin.tokens.write") -async def create_token(tokens: list[TokenInfo], session=Depends(get_session)) -> WCSResponse[list[TokenInfoResponse]]: +@ManageRouter.get("/user/all", summary="List Users") +@metadata.permission("admin.users.read") +async def get_users( + session: SessionDep, + inactive: bool = False, +) -> WCSResponse[list[UserInfoResponse]]: """ - Create a new API token with specified permissions and expiration. + List all users with their permissions. """ - created_tokens: list[TokenInfoResponse] = [] - for token_info in tokens: - user = await try_create_user( - session, - token_str=hash_token(token_info.token), - permissions=token_info.permissions, - expires_at=token_info.expires_at, - ) - created_tokens.append(TokenInfoResponse.model_validate(user.token)) - - LOGGER.info(f"Created {len(created_tokens)} new API tokens: {[t.token for t in created_tokens]}") - return WCSResponse(data=created_tokens) + userRepo = UserRepository(session) + users = await userRepo.list_users(include_inactive=inactive) + return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) -@ManageRouter.delete("/tokens", summary="Delete API Token") -@metadata.permission(permission="admin.tokens.write") -async def delete_token(tokens: list[str], session=Depends(get_session)) -> WCSResponse[dict]: +@ManageRouter.get("/user/self", summary="Get Self User Info") +async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]: """ - Delete an API token by its token string. + Get information about the current user, including permissions and common IPs. """ - tokens = hash_tokens(tokens) - await try_delete_token(session, tokens) - LOGGER.info(f"Deleted {len(tokens)} API tokens: {tokens}") - return EMPTY_RESPONSE + return WCSResponse(data=UserInfoResponse.model_validate(user)) -@ManageRouter.put("/tokens/permissions", summary="Add Permissions to Token") -@metadata.permission(permission="admin.tokens.write") -async def add_permissions_to_token( - token_str: list[str], permissions: list[str], session=Depends(get_session) -) -> WCSResponse[list[TokenInfoResponse]]: +@ManageRouter.post("/user/register", summary="Register New User") +@metadata.rate_limit(1, 3600) +async def register_user( + token: str, + session: SessionDep, + ip: IpDep = "unknown", +) -> WCSResponse[UserInfoResponse]: """ - Add permissions to existing API tokens. + Register a new user with the given token. """ - token_strs = hash_tokens(token_str) - tokens = await list_tokens(session, token_strs, include_inactive=True) - - for token in tokens: - token.permissions = list(set(token.permissions + permissions)) - - updated_token_infos = [TokenInfoResponse.model_validate(token) for token in tokens] - LOGGER.info(f"Added permissions {permissions} to tokens: {token_strs}") - return WCSResponse(data=updated_token_infos) + userRepo = UserRepository(session) + user = User( + token=hash_token(token), + is_active=True, + permissions=[], + creation_ip=ip, + ) + await userRepo.save(user) + LOGGER.info(f"Registered new user with token: {token}") + return WCSResponse(data=UserInfoResponse.model_validate(user)) -@ManageRouter.delete("/tokens/permissions", summary="Remove Permissions from Token") -@metadata.permission(permission="admin.tokens.write") -async def remove_permissions_from_token( - token_str: list[str], permissions: list[str], session=Depends(get_session) -) -> WCSResponse[list[TokenInfoResponse]]: +@ManageRouter.post("/user/create", summary="Create New Users") +@metadata.permission(permission="admin.users.write") +async def create_user( + tokens: list[UserInfoRequest], session: SessionDep, ip: IpDep = "unknown" +) -> WCSResponse[list[UserInfoResponse]]: """ - Remove permissions from existing API tokens. + Create a new user with specified token and permissions. """ - token_strs = hash_tokens(token_str) - tokens = await list_tokens(session, token_strs, include_inactive=True) - - for token in tokens: - token.permissions = list(set(token.permissions) - set(permissions)) + userRepo = UserRepository(session) + created_tokens: list[UserInfoResponse] = [] + for token_info in tokens: + user = User( + token=hash_token(token_info.token), + is_active=True, + permissions=token_info.permissions, + expires_at=token_info.expires_at, + creation_ip=ip, + ) + await userRepo.save(user) + created_tokens.append(UserInfoResponse.model_validate(user)) - updated_token_infos = [TokenInfoResponse.model_validate(token) for token in tokens] - LOGGER.info(f"Removed permissions {permissions} from tokens: {token_strs}") - return WCSResponse(data=updated_token_infos) + LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}") + return WCSResponse(data=created_tokens) -@ManageRouter.get("/tokens/self", summary="Get Self Token Info") -async def get_self_token_info(user: User = Depends(get_user)) -> WCSResponse[TokenInfoResponse]: +@ManageRouter.delete("/user", summary="Delete User") +@metadata.permission(permission="admin.users.write") +async def delete_user(tokens: list[str], session: SessionDep) -> WCSResponse[dict]: """ - Get information about the API token used in the current request. + Delete a user by their token string. """ - return WCSResponse(data=TokenInfoResponse.model_validate(user.token)) + tokens = hash_tokens(tokens) + userRepo = UserRepository(session) + await userRepo.delete(tokens) + LOGGER.info(f"Deleted {len(tokens)} users: {tokens}") + return EMPTY_RESPONSE -@ManageRouter.get("/user/self", summary="Get Self User Info") -async def get_self_user_info(user: User = Depends(get_user)) -> WCSResponse[UserInfoResponse]: +@ManageRouter.put("/user/permissions", summary="Add Permissions to User") +@metadata.permission(permission="admin.users.write") +async def add_permissions_to_user( + token_str: list[str], permissions: list[str], session: SessionDep +) -> WCSResponse[list[UserInfoResponse]]: """ - Get information about the current user, including permissions and common IPs. + Add permissions to existing users. """ - return WCSResponse(data=UserInfoResponse.model_validate(user)) + userRepo = UserRepository(session) + token_strs = hash_tokens(token_str) + users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) + for user in users: + user.permissions = list(set(user.permissions + permissions)) -@ManageRouter.get("/user", summary="Get User Info by Token") -@metadata.permission(permission="admin.tokens.read") -async def get_user_info_by_token( - token: list[str] = Query(), session=Depends(get_session) + updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] + LOGGER.info(f"Added permissions {permissions} to users: {token_strs}") + return WCSResponse(data=updated_user_infos) + + +@ManageRouter.delete("/user/permissions", summary="Remove Permissions from User") +@metadata.permission(permission="admin.users.write") +async def remove_permissions_from_user( + token_str: list[str], permissions: list[str], session: SessionDep ) -> WCSResponse[list[UserInfoResponse]]: """ - Get information about a user by their API token. + Remove permissions from existing users. """ - tokens = hash_tokens(token) - - users = await get_user_by_tokens(session, tokens) - return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) + token_strs = hash_tokens(token_str) + userRepo = UserRepository(session) + users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) + for user in users: + user.permissions = list(set(user.permissions) - set(permissions)) -@ManageRouter.post("/user/register", summary="Register New User") -@metadata.rate_limit(1, 3600) -async def register_user( - token: str, - x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, - session=Depends(get_session), -) -> WCSResponse[UserInfoResponse]: - """ - Register a new user with the given token. - """ - hashed_token = hash_token(token) - user = await try_create_user( - session, token_str=hashed_token, permissions=[], expires_at=None, creation_ip=x_real_ip or "unknown" - ) - LOGGER.info(f"Registered new user with token: {token}") - return WCSResponse(data=UserInfoResponse.model_validate(user)) + updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] + LOGGER.info(f"Removed permissions {permissions} from users: {token_strs}") + return WCSResponse(data=updated_user_infos) diff --git a/app/module/manage/schema.py b/app/module/manage/schema.py index 2e5f366..78d358c 100644 --- a/app/module/manage/schema.py +++ b/app/module/manage/schema.py @@ -1,25 +1,28 @@ -import datetime +from datetime import datetime from pydantic import BaseModel, ConfigDict -class TokenInfo(BaseModel): - model_config = ConfigDict(from_attributes=True) - +class UserInfoRequest(BaseModel): token: str permissions: list[str] - expires_at: datetime.datetime | None + expires_at: datetime | None = None -class TokenInfoResponse(TokenInfo): +class UserInfoResponse(BaseModel): model_config = ConfigDict(from_attributes=True) - created_at: datetime.datetime + id: int + + token: str + permissions: list[str] + created_at: datetime + expires_at: datetime | None = None + is_active: bool = True -class UserInfoResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) + creation_ip: str + # A list of common IPs used by this user, ordered from oldest to newest. + common_ips: list[str] - token: TokenInfoResponse score: int - common_ips: list[str] diff --git a/app/module/market/router.py b/app/module/market/router.py new file mode 100644 index 0000000..fd55c88 --- /dev/null +++ b/app/module/market/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from app.core.router import DocedAPIRoute +from app.schemas.enums import ApiTag + +MarketRouter = APIRouter(route_class=DocedAPIRoute, prefix="/market", tags=[ApiTag.MARKET]) diff --git a/app/module/pool/config.py b/app/module/pool/config.py index ecc0fbe..a52ed93 100644 --- a/app/module/pool/config.py +++ b/app/module/pool/config.py @@ -1,26 +1,63 @@ -from datetime import time, timedelta +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta -from .schema import LootPoolType +from app.core.score import Tier + +from .schema import PoolType + + +@dataclass +class PoolRotation: + start: datetime + end: datetime + + +@dataclass +class PoolConfig: + interval: timedelta + anchor: datetime + + def get_rotation(self, time: datetime, shift: int = 0) -> PoolRotation: + if time.tzinfo is None: + raise ValueError("The 'time' parameter must be timezone-aware.") + + base_cycles = (time - self.anchor) // self.interval + target_cycles = base_cycles + shift + start = self.anchor + (self.interval * target_cycles) + + return PoolRotation(start=start, end=start + self.interval) + + +GLOBAL_RESET_ANCHOR = datetime(2026, 2, 20, 18, 0, 0, tzinfo=UTC) POOL_REFRESH_CONFIG = { - LootPoolType.ITEM: { - "interval": timedelta(days=7), - "anchor": time(0, 0, 0), - }, - LootPoolType.RAID_ASPECT: { - "interval": timedelta(hours=24), - "anchor": time(0, 0, 0), - }, - LootPoolType.RAID_TOME: { - "interval": timedelta(hours=24), - "anchor": time(0, 0, 0), - }, + PoolType.ITEM: PoolConfig( + interval=timedelta(days=7), + anchor=GLOBAL_RESET_ANCHOR, + ), + PoolType.RAID_ASPECT: PoolConfig( + interval=timedelta(days=7), + anchor=GLOBAL_RESET_ANCHOR, + ), + PoolType.RAID_TOME: PoolConfig( + interval=timedelta(days=7), + anchor=GLOBAL_RESET_ANCHOR, + ), } + FUZZY_WINDOW = timedelta(minutes=10) -SCORE_CONFIG = { - "min": -10000, - "max": 10000, - # TODO more sophisticated scoring config +WEIGHT_MAP: dict[Tier | str, float] = { + Tier.Rookie: 0.1, + Tier.Assistant: 0.3, + Tier.Sentinel: 0.8, + Tier.Elite: 1.5, + Tier.Admiral: 3.0, + Tier.Commander: 5.0, + Tier.Master: 7.5, + Tier.Grandmaster: 9.0, + "max": 10.0, } + +CONSENSUS_THRESHOLD = 0.6 diff --git a/app/module/pool/model.py b/app/module/pool/model.py index 5ac3e23..7c76976 100644 --- a/app/module/pool/model.py +++ b/app/module/pool/model.py @@ -1,10 +1,27 @@ from datetime import datetime - -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, UniqueConstraint, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.core.db import Base +from typing import Literal + +from sqlalchemy import ( + ARRAY, + Boolean, + DateTime, + Float, + ForeignKey, + Integer, + LargeBinary, + String, + UniqueConstraint, + func, + select, +) +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload + +from app.core.db import Base, BaseRepository from app.core.security.model import User +from app.module.pool.schema import PoolType + +from .config import PoolRotation class Pool(Base): @@ -19,9 +36,9 @@ class Pool(Base): rotation_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) rotation_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - consensus_data: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + consensus_data: Mapped[list[bytes]] = mapped_column(ARRAY(LargeBinary), nullable=False, default=[]) last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) submission_count: Mapped[int] = mapped_column(Integer, default=0) needs_recalc: Mapped[bool] = mapped_column(Boolean, default=True) @@ -43,17 +60,121 @@ class PoolSubmission(Base): rotation_id: Mapped[int | None] = mapped_column( ForeignKey("pools.id", ondelete="CASCADE"), index=True, nullable=True ) + rotation: Mapped[Pool | None] = relationship("Pool", back_populates="submissions") user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + user: Mapped[User] = relationship("User") + submitted_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), ) client_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + mod_version: Mapped[str] = mapped_column(String(50), nullable=False) + fuzzy: Mapped[bool] = mapped_column(Boolean, default=False) - item_data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + item_data: Mapped[list[bytes]] = mapped_column(ARRAY(LargeBinary), nullable=False) weight: Mapped[float] = mapped_column(Float, nullable=False) - is_fuzzy: Mapped[bool] = mapped_column(Boolean, default=False) - rotation: Mapped["Pool"] = relationship("Pool", back_populates="submissions") - user: Mapped["User"] = relationship("User") + +class PoolRepository(BaseRepository): + async def get_by_key(self, pool_type: PoolType, region: str, page: int, rotation_start: datetime) -> Pool | None: + query = select(Pool).where( + Pool.pool_type == pool_type.value, + Pool.region == region, + Pool.page == page, + Pool.rotation_start == rotation_start, + ) + result = await self.session.execute(query) + return result.scalars().first() + + async def list_pools( + self, + pool_type: PoolType | None = None, + region: str | None = None, + page: int | None = None, + rotation_start: datetime | None = None, + needs_recalc: bool | None = None, + order_by: Literal["rotation_start", "page"] | None = None, + ) -> list[Pool]: + query = select(Pool).options(selectinload(Pool.submissions)) + if pool_type is not None: + query = query.where(Pool.pool_type == pool_type.value) + if region is not None: + query = query.where(Pool.region == region) + if page is not None: + query = query.where(Pool.page == page) + if rotation_start: + query = query.where(Pool.rotation_start == rotation_start) + if needs_recalc is not None: + query = query.where(Pool.needs_recalc == needs_recalc) + + if order_by == "rotation_start": + query = query.order_by(Pool.rotation_start) + elif order_by == "page": + query = query.order_by(Pool.page) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def save(self, pool: Pool) -> None: + self.session.add(pool) + await self.session.flush() + await self.session.refresh(pool) + + async def delete(self, pool: Pool) -> None: + await self.session.delete(pool) + await self.session.flush() + + async def get_or_create_pool(self, pool_type: PoolType, region: str, page: int, rotation: PoolRotation) -> Pool: + existing_pool = await self.get_by_key( + pool_type=pool_type, + region=region, + page=page, + rotation_start=rotation.start, + ) + if existing_pool: + return existing_pool + + try: + async with self.session.begin_nested(): + new_pool = Pool( + pool_type=pool_type.value, + region=region, + page=page, + rotation_start=rotation.start, + rotation_end=rotation.end, + ) + await self.save(new_pool) + return new_pool + except IntegrityError: + return await self.get_or_create_pool(pool_type, region, page, rotation) + + +class PoolSubmissionRepository(BaseRepository): + async def save(self, submission: PoolSubmission) -> None: + self.session.add(submission) + await self.session.flush() + await self.session.refresh(submission) + + async def delete(self, submission: PoolSubmission) -> None: + await self.session.delete(submission) + await self.session.flush() + + async def list_submissions_for_rotation(self, rotation_id: int) -> list[PoolSubmission]: + query = select(PoolSubmission).where(PoolSubmission.rotation_id == rotation_id) + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def get_user_submission_for_rotation(self, user_id: int, rotation_id: int) -> PoolSubmission | None: + query = select(PoolSubmission).where( + PoolSubmission.user_id == user_id, + PoolSubmission.rotation_id == rotation_id, + ) + result = await self.session.execute(query) + return result.scalars().first() + + async def list_submissions_by_user(self, user_id: int) -> list[PoolSubmission]: + query = select(PoolSubmission).where(PoolSubmission.user_id == user_id) + result = await self.session.execute(query) + return list(result.scalars().all()) diff --git a/app/module/pool/router.py b/app/module/pool/router.py index af7f479..9d1f414 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -1,21 +1,57 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession +import datetime + +from fastapi import APIRouter, HTTPException +from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT from app.core import metadata -from app.core.db import get_session -from app.core.rate_limiter import user_based_key_func +from app.core.db import SessionDep, get_session +from app.core.rate_limiter import ip_based_key_func, user_based_key_func from app.core.router import DocedAPIRoute +from app.core.security.auth import UserDep +from app.schemas.enums import ApiTag +from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse -from .schema import PoolSubmissionSchema -from .service import submit_pool_data +from .config import POOL_REFRESH_CONFIG +from .schema import LootPoolRegion, PoolConsensusResponse, PoolSubmissionSchema, PoolType, RaidRegion +from .service import get_pool_consensus, submit_pool_data -PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=["pool"]) +PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL]) @PoolRouter.post("/submit", summary="Submit Pool Data") @metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) -async def handle_pool_data_submission(data: PoolSubmissionSchema, session: AsyncSession = Depends(get_session)): +async def handle_pool_data_submission(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse: """ Endpoint for clients to submit pool data. """ - return await submit_pool_data(session, data) + for submission in data: + try: + # We want to process each submission in a different sessions to avoid one bad submission breaking others + async with get_session() as session: + await submit_pool_data(session, submission, user) + except ValueError: + continue + + return EMPTY_RESPONSE + + +@PoolRouter.get("/pools/{pool_type}/{region}", summary="Get Current Pool by Type and Region") +@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func) +@metadata.cached(expire=120) +async def get_pools_by_type_and_region( + pool_type: PoolType, region: LootPoolRegion | RaidRegion, session: SessionDep +) -> WCSResponse[PoolConsensusResponse]: + """ + Get pools by type and region. + """ + try: + data = await get_pool_consensus( + session, + pool_type, + region, + rotation_start=POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)).start, + ) + except ValueError as e: + raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) + + return WCSResponse(data=data) diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index ef4b91f..74de0ab 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field -class LootPoolType(enum.StrEnum): +class PoolType(enum.StrEnum): ITEM = "lr_item_pool" RAID_ASPECT = "raid_aspect_pool" RAID_TOME = "raid_tome_pool" @@ -34,16 +34,34 @@ class RaidRegion(enum.StrEnum): NOTG = "NOTG" -VALID_REGIONS: dict[LootPoolType, type[enum.StrEnum]] = { - LootPoolType.ITEM: LootPoolRegion, - LootPoolType.RAID_ASPECT: RaidRegion, - LootPoolType.RAID_TOME: RaidRegion, +VALID_REGIONS: dict[PoolType, type[enum.StrEnum]] = { + PoolType.ITEM: LootPoolRegion, + PoolType.RAID_ASPECT: RaidRegion, + PoolType.RAID_TOME: RaidRegion, } class PoolSubmissionSchema(BaseModel): - pool_type: LootPoolType - region: str = Field(description="Region or raid for the loot pool, e.g. 'Sky', 'TNA', etc.") + pool_type: PoolType + region: LootPoolRegion | RaidRegion page: int client_timestamp: datetime - items: list[str] = Field(description="base64-encoded protobuf bytes for item") + mod_version: str + items: list[str] = Field( + description="base64-encoded protobuf bytes for item", + json_schema_extra={"example": ["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]}, + ) + + +class PoolConsensusResponse(BaseModel): + pool_type: PoolType + region: LootPoolRegion | RaidRegion + page_consensus: list["PageConsensus"] + + class PageConsensus(BaseModel): + page: int = Field(description="The page number") + items: list[str] = Field( + description="Consensus item data (protobuf bytes in base64 encoding)", + json_schema_extra={"example": ["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]}, + ) + confidence: float = Field(description="Confidence level, between 0 and 1") diff --git a/app/module/pool/service.py b/app/module/pool/service.py index 71f191d..1e5df23 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -1,13 +1,103 @@ +import base64 +import datetime +from collections import defaultdict + from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy.ext.asyncio import AsyncSession +from app.core.db import get_session from app.core.scheduler import SCHEDULER -from app.module.pool.schema import PoolSubmissionSchema +from app.core.score import Tier +from app.core.security.model import User +from wynnsource import WynnSourceItem + +from .config import CONSENSUS_THRESHOLD, FUZZY_WINDOW, POOL_REFRESH_CONFIG, WEIGHT_MAP +from .model import PoolRepository, PoolSubmission, PoolSubmissionRepository +from .schema import VALID_REGIONS, LootPoolRegion, PoolConsensusResponse, PoolSubmissionSchema, PoolType, RaidRegion + + +async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, user: User): + # validation + # pool id check + if data.region not in VALID_REGIONS[data.pool_type]: + raise ValueError(f"Invalid region {data.region} for pool type {data.pool_type}") + + # client timestamp check (we allow skew up to 10 minutes) + now = datetime.datetime.now(tz=datetime.UTC) + if abs(now - data.client_timestamp) > datetime.timedelta(minutes=10): + raise ValueError("Client timestamp is too far from server time") + + # items check and decoding + items_decoded: list[bytes] = [] + for item in data.items: + try: + decoded = base64.b64decode(item) + tmp = WynnSourceItem.FromString(decoded) # noqa: F841 + # we just want to make sure it can is a valid item + items_decoded.append(decoded) + except Exception: + continue # we silently skip invalid items + + if not items_decoded: + raise ValueError("No valid items provided in the submission") + + poolRepo = PoolRepository(session) + submissionRepo = PoolSubmissionRepository(session) + pool = await poolRepo.get_or_create_pool( + pool_type=data.pool_type, + region=data.region, + page=data.page, + rotation=POOL_REFRESH_CONFIG[data.pool_type].get_rotation(data.client_timestamp), + ) -async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema): - # TODO: Implement logic to save the submitted pool data to the database and trigger any necessary processing. - pass + # no pool binded to submission yet + fuzzy = ( + abs(pool.rotation_start - data.client_timestamp) < FUZZY_WINDOW + or abs(pool.rotation_end - data.client_timestamp) < FUZZY_WINDOW + ) + submission = PoolSubmission( + user=user, + client_timestamp=data.client_timestamp, + item_data=items_decoded, + weight=calculate_submission_weight(user, fuzzy), + fuzzy=fuzzy, + mod_version=data.mod_version, + ) + + # user can have one submission of each pool for each rotation + existingSubmission = await submissionRepo.get_user_submission_for_rotation(user.id, pool.id) + + if existingSubmission is not None: + # if the new submission is different from the existing one, we replace it + # the existing one still stay in the user's submission history + if existingSubmission.item_data != submission.item_data: + existingSubmission.item_data = submission.item_data + existingSubmission.client_timestamp = submission.client_timestamp + existingSubmission.weight = submission.weight + existingSubmission.mod_version = submission.mod_version + pool.needs_recalc = True + else: + submission.rotation = pool + pool.submission_count += 1 + pool.needs_recalc = True + await submissionRepo.save(submission) + + +def calculate_submission_weight(user: User, fuzzy: bool = False) -> float: + if user.score < 0: + # For users with negative scores, we directly map (-10000, 0) to (0.01, 0.1) on a logarithmic scale + return max(0.0001, min(0.1, 0.1 * (10 ** (user.score / 10000)))) + else: + tier = Tier.get_by_score(user.score) + weight_range = WEIGHT_MAP[tier] + next_tier = tier.next() + next_weight_range = WEIGHT_MAP[next_tier] if next_tier else WEIGHT_MAP["max"] + + weight = weight_range + (next_weight_range - weight_range) * ( + (user.score - tier.score_range.min) / (tier.score_range.max - tier.score_range.min) + ) + return weight * (0.5 if fuzzy else 1.0) @SCHEDULER.scheduled_job( @@ -16,6 +106,86 @@ async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema): misfire_grace_time=60, # Allow a 1-minute grace period for missed executions coalesce=True, # Coalesce multiple missed executions into one ) -def compute_pool_consensus(): - # TODO: Implement logic to compute consensus data for active pools based on recent submissions - pass +async def compute_pool_consensus(): + for pool_type in PoolType: + await compute_pool_consensus_for_pool(pool_type) + + +async def compute_pool_consensus_for_pool(pool_type: PoolType): + async with get_session() as session: + # Step 1: Fetch all active pools that need consensus computation + poolRepo = PoolRepository(session) + active_pools = await poolRepo.list_pools( + pool_type=pool_type, + rotation_start=POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)).start, + needs_recalc=True, + ) + # The active pools should only differ in (region, page) + + # Step 2: For each pool, compute consensus and update the pool record + for pool in active_pools: + submissions = pool.submissions + + item_weights: dict[bytes, float] = defaultdict(float) + + for submission in submissions: + for item_data in submission.item_data: + item_weights[item_data] += submission.weight + + if not item_weights: + pool.consensus_data = [] + pool.confidence = 0.0 + pool.needs_recalc = False + continue + + highest_weight = max(item_weights.values()) + + if highest_weight <= 0: + pool.consensus_data = list(item_weights.keys()) + pool.confidence = 0.0 + pool.needs_recalc = False + continue + + threshold = highest_weight * CONSENSUS_THRESHOLD + + consensus_items = [] + consensus_weights = [] + + for item, weight in item_weights.items(): + if weight >= threshold: + consensus_items.append(item) + consensus_weights.append(weight) + + pool.consensus_data = consensus_items + confidence = ( + sum(consensus_weights) / (highest_weight * len(consensus_weights)) if consensus_weights else 0.0 + ) + pool.confidence = round(confidence, 4) + pool.needs_recalc = False + + +async def get_pool_consensus( + session: AsyncSession, pool_type: PoolType, region: LootPoolRegion | RaidRegion, rotation_start: datetime.datetime +) -> PoolConsensusResponse: + + if region not in VALID_REGIONS[pool_type]: + raise ValueError(f"Invalid region {region} for pool type {pool_type}") + + poolRepo = PoolRepository(session) + + pool = await poolRepo.list_pools(pool_type=pool_type, region=region, rotation_start=rotation_start, order_by="page") + if not pool: + return PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=[]) + + consensus_by_page: dict[int, tuple[list[str], float]] = {} + for p in pool: + consensus_by_page[p.page] = [base64.b64encode(item).decode() for item in p.consensus_data], p.confidence + + return PoolConsensusResponse( + pool_type=pool_type, + region=region, + page_consensus=[ + PoolConsensusResponse.PageConsensus(page=page, items=items, confidence=confidence) + for page, (items, confidence) in consensus_by_page.items() + ], + ) diff --git a/app/schemas/enums/__init__.py b/app/schemas/enums/__init__.py index 375425b..3a75d09 100644 --- a/app/schemas/enums/__init__.py +++ b/app/schemas/enums/__init__.py @@ -1,5 +1,7 @@ from .error import ErrorCodes +from .tag import ApiTag __all__ = [ + "ApiTag", "ErrorCodes", ] diff --git a/app/schemas/enums/tag.py b/app/schemas/enums/tag.py new file mode 100644 index 0000000..51c61c8 --- /dev/null +++ b/app/schemas/enums/tag.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class ApiTag(StrEnum): + MANAGEMENT = "management" + POOL = "pool" + MARKET = "market" diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py index 893b365..dfbcc31 100644 --- a/app/schemas/response/__init__.py +++ b/app/schemas/response/__init__.py @@ -1,4 +1,4 @@ from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE -from .response import StatusResponse, WCSResponse +from .response import EmptyResponse, StatusResponse, WCSResponse -__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "StatusResponse", "WCSResponse"] +__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "EmptyResponse", "StatusResponse", "WCSResponse"] diff --git a/app/schemas/response/default_response.py b/app/schemas/response/default_response.py index ac72c56..3363bed 100644 --- a/app/schemas/response/default_response.py +++ b/app/schemas/response/default_response.py @@ -1,4 +1,4 @@ -from .response import StatusResponse, WCSResponse +from .response import EmptyResponse, StatusResponse STATUS_RESPONSE = StatusResponse() -EMPTY_RESPONSE = WCSResponse(data={}) +EMPTY_RESPONSE = EmptyResponse() diff --git a/app/schemas/response/response.py b/app/schemas/response/response.py index 51198f0..0130fda 100644 --- a/app/schemas/response/response.py +++ b/app/schemas/response/response.py @@ -9,14 +9,18 @@ from app.schemas.enums import ErrorCodes -class WCSResponse[T: (BaseModel, dict, list)](BaseModel): +class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel): """ Base class for all v1 response models. """ data: T code: ErrorCodes = Field(default=ErrorCodes.OK) - timestamp: int = Field(default_factory=lambda: int(datetime.now(UTC).timestamp()), frozen=True) + timestamp: int = Field( + default_factory=lambda: int(datetime.now(UTC).timestamp()), + frozen=True, + json_schema_extra={"example": int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())}, + ) version: int = Field(default=__REVISION__, frozen=True) def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse: @@ -64,3 +68,11 @@ class StatusResponse(WCSResponse[StatusData]): """ data: StatusData = Field(default=StatusData(), frozen=True) + + +class EmptyResponse(WCSResponse[dict]): + """ + Empty response model for v1. + """ + + data: dict = Field(default={}, frozen=True, json_schema_extra={"example": {}}) diff --git a/migration/versions/507737e6b05e_init.py b/migration/versions/507737e6b05e_init.py deleted file mode 100644 index 6f2aeff..0000000 --- a/migration/versions/507737e6b05e_init.py +++ /dev/null @@ -1,51 +0,0 @@ -"""init - -Revision ID: 507737e6b05e -Revises: -Create Date: 2026-02-18 18:08:31.882668 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '507737e6b05e' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('permissions', sa.ARRAY(sa.String()), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_tokens_token'), 'tokens', ['token'], unique=True) - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('score', sa.Integer(), nullable=False), - sa.Column('common_ips', sa.ARRAY(sa.String()), nullable=False), - sa.Column('creation_ip', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['id'], ['tokens.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('users') - op.drop_index(op.f('ix_tokens_token'), table_name='tokens') - op.drop_table('tokens') - # ### end Alembic commands ### diff --git a/migration/versions/938cca31bfed_init.py b/migration/versions/938cca31bfed_init.py new file mode 100644 index 0000000..0df9655 --- /dev/null +++ b/migration/versions/938cca31bfed_init.py @@ -0,0 +1,80 @@ +"""init + +Revision ID: 938cca31bfed +Revises: +Create Date: 2026-02-21 19:35:10.056243 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '938cca31bfed' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pools', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('pool_type', sa.String(length=50), nullable=False), + sa.Column('region', sa.String(length=50), nullable=False), + sa.Column('page', sa.Integer(), nullable=False), + sa.Column('rotation_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('rotation_end', sa.DateTime(timezone=True), nullable=False), + sa.Column('consensus_data', sa.ARRAY(sa.LargeBinary()), nullable=True), + sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('confidence', sa.Float(), nullable=True), + sa.Column('submission_count', sa.Integer(), nullable=False), + sa.Column('needs_recalc', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('pool_type', 'region', 'page', 'rotation_start', name='uq_pool_rotation_key') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('permissions', sa.ARRAY(sa.String()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('creation_ip', sa.String(), nullable=False), + sa.Column('common_ips', sa.ARRAY(sa.String()), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_token'), 'users', ['token'], unique=True) + op.create_table('pool_submissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('rotation_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('submitted_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('client_timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('mod_version', sa.String(length=50), nullable=False), + sa.Column('fuzzy', sa.Boolean(), nullable=False), + sa.Column('item_data', sa.ARRAY(sa.LargeBinary()), nullable=False), + sa.Column('weight', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['rotation_id'], ['pools.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pool_submissions_rotation_id'), 'pool_submissions', ['rotation_id'], unique=False) + op.create_index(op.f('ix_pool_submissions_user_id'), 'pool_submissions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_pool_submissions_user_id'), table_name='pool_submissions') + op.drop_index(op.f('ix_pool_submissions_rotation_id'), table_name='pool_submissions') + op.drop_table('pool_submissions') + op.drop_index(op.f('ix_users_token'), table_name='users') + op.drop_table('users') + op.drop_table('pools') + # ### end Alembic commands ### From ab7eda44838ec94e07a5b0939e8618a5230e271d Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 22 Feb 2026 11:29:00 -0500 Subject: [PATCH 06/78] feat: add item response type and force recalc --- app/core/router.py | 10 ++++++++ app/module/pool/router.py | 43 +++++++++++++++++++++++++++----- app/module/pool/schema.py | 4 +-- app/module/pool/service.py | 24 ++++++++---------- app/schemas/enums/__init__.py | 2 ++ app/schemas/enums/item.py | 35 ++++++++++++++++++++++++++ app/schemas/response/response.py | 2 -- 7 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 app/schemas/enums/item.py diff --git a/app/core/router.py b/app/core/router.py index 30647a2..a3856ce 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -6,6 +6,7 @@ from fastapi import Depends, Request, Response, params from fastapi.routing import APIRoute +from starlette.routing import get_name from app.core.cache import cached from app.core.metadata import EndpointMetadata @@ -26,9 +27,14 @@ def __init__( responses: dict[int | str, dict[str, Any]] | None = None, description: str | None = None, tags: list[str | Enum] | None = None, + name: str | None = None, + operation_id: str | None = None, **kwargs, ): + name = get_name(endpoint) if name is None else name + operation_id = operation_id or name + # meta related processing meta: EndpointMetadata = getattr(endpoint, "__metadata__", EndpointMetadata()) @@ -40,6 +46,8 @@ def __init__( responses=responses, description=description, tags=tags, + name=name, + operation_id=operation_id, **kwargs, ) return @@ -116,6 +124,8 @@ def __init__( responses=responses, description=description, tags=tags, + name=name, + operation_id=operation_id, **kwargs, ) diff --git a/app/module/pool/router.py b/app/module/pool/router.py index 9d1f414..b03dc07 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -8,19 +8,20 @@ from app.core.rate_limiter import ip_based_key_func, user_based_key_func from app.core.router import DocedAPIRoute from app.core.security.auth import UserDep -from app.schemas.enums import ApiTag +from app.schemas.enums import ApiTag, ItemReturnType from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse from .config import POOL_REFRESH_CONFIG from .schema import LootPoolRegion, PoolConsensusResponse, PoolSubmissionSchema, PoolType, RaidRegion -from .service import get_pool_consensus, submit_pool_data +from .service import compute_pool_consensus, get_pool_consensus +from .service import submit_pool_data as svc_submit_pool_data PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL]) @PoolRouter.post("/submit", summary="Submit Pool Data") @metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) -async def handle_pool_data_submission(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse: +async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse: """ Endpoint for clients to submit pool data. """ @@ -28,7 +29,7 @@ async def handle_pool_data_submission(data: list[PoolSubmissionSchema], user: Us try: # We want to process each submission in a different sessions to avoid one bad submission breaking others async with get_session() as session: - await submit_pool_data(session, submission, user) + await svc_submit_pool_data(session, submission, user) except ValueError: continue @@ -39,19 +40,49 @@ async def handle_pool_data_submission(data: list[PoolSubmissionSchema], user: Us @metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func) @metadata.cached(expire=120) async def get_pools_by_type_and_region( - pool_type: PoolType, region: LootPoolRegion | RaidRegion, session: SessionDep + pool_type: PoolType, + region: LootPoolRegion | RaidRegion, + session: SessionDep, + item_return_type: ItemReturnType = ItemReturnType.B64, ) -> WCSResponse[PoolConsensusResponse]: """ Get pools by type and region. """ try: - data = await get_pool_consensus( + consensus_by_page = await get_pool_consensus( session, pool_type, region, rotation_start=POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)).start, ) + + if not consensus_by_page: + data = PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=[]) + else: + page_consensus = [] + for page, (items, confidence) in consensus_by_page.items(): + page_consensus.append( + PoolConsensusResponse.PageConsensus( + page=page, + items=item_return_type.format_items(items), + confidence=confidence, + ) + ) + + data = PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=page_consensus) + except ValueError as e: raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) return WCSResponse(data=data) + + +@PoolRouter.get("/pools/recalc", summary="Force Recalculate Pool Consensus") +@metadata.permission("pool.recalc") +async def recalculate_pools() -> EmptyResponse: + """ + Force recalculate pool consensus. This is useful for admins to fix any issues with the consensus calculation. + """ + + await compute_pool_consensus() + return EMPTY_RESPONSE diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index 74de0ab..d5d107b 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -60,8 +60,8 @@ class PoolConsensusResponse(BaseModel): class PageConsensus(BaseModel): page: int = Field(description="The page number") - items: list[str] = Field( - description="Consensus item data (protobuf bytes in base64 encoding)", + items: list[str] | list[dict] = Field( + description="Consensus item data for the page, format depends on item_return_type query parameter", json_schema_extra={"example": ["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]}, ) confidence: float = Field(description="Confidence level, between 0 and 1") diff --git a/app/module/pool/service.py b/app/module/pool/service.py index 1e5df23..e1dfb5b 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -13,7 +13,7 @@ from .config import CONSENSUS_THRESHOLD, FUZZY_WINDOW, POOL_REFRESH_CONFIG, WEIGHT_MAP from .model import PoolRepository, PoolSubmission, PoolSubmissionRepository -from .schema import VALID_REGIONS, LootPoolRegion, PoolConsensusResponse, PoolSubmissionSchema, PoolType, RaidRegion +from .schema import VALID_REGIONS, LootPoolRegion, PoolSubmissionSchema, PoolType, RaidRegion async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, user: User): @@ -164,9 +164,12 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): pool.needs_recalc = False +type ConsensusByPage = dict[int, tuple[list[bytes], float]] + + async def get_pool_consensus( session: AsyncSession, pool_type: PoolType, region: LootPoolRegion | RaidRegion, rotation_start: datetime.datetime -) -> PoolConsensusResponse: +) -> ConsensusByPage: if region not in VALID_REGIONS[pool_type]: raise ValueError(f"Invalid region {region} for pool type {pool_type}") @@ -175,17 +178,10 @@ async def get_pool_consensus( pool = await poolRepo.list_pools(pool_type=pool_type, region=region, rotation_start=rotation_start, order_by="page") if not pool: - return PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=[]) + return {} - consensus_by_page: dict[int, tuple[list[str], float]] = {} + consensus_by_page: ConsensusByPage = {} for p in pool: - consensus_by_page[p.page] = [base64.b64encode(item).decode() for item in p.consensus_data], p.confidence - - return PoolConsensusResponse( - pool_type=pool_type, - region=region, - page_consensus=[ - PoolConsensusResponse.PageConsensus(page=page, items=items, confidence=confidence) - for page, (items, confidence) in consensus_by_page.items() - ], - ) + consensus_by_page[p.page] = p.consensus_data, p.confidence + + return consensus_by_page diff --git a/app/schemas/enums/__init__.py b/app/schemas/enums/__init__.py index 3a75d09..9cc563f 100644 --- a/app/schemas/enums/__init__.py +++ b/app/schemas/enums/__init__.py @@ -1,7 +1,9 @@ from .error import ErrorCodes +from .item import ItemReturnType from .tag import ApiTag __all__ = [ "ApiTag", "ErrorCodes", + "ItemReturnType", ] diff --git a/app/schemas/enums/item.py b/app/schemas/enums/item.py new file mode 100644 index 0000000..2c47c17 --- /dev/null +++ b/app/schemas/enums/item.py @@ -0,0 +1,35 @@ +from base64 import b64encode +from enum import StrEnum + +from google.protobuf import json_format + +from wynnsource import WynnSourceItem + + +class ItemReturnType(StrEnum): + B64 = "b64" + JSON = "json" + NAME = "name" + + def format_items(self, items: list[bytes]) -> list[str] | list[dict]: + match self: + case ItemReturnType.B64: + return [b64encode(item).decode() for item in items] + case ItemReturnType.JSON: + formatted_items = [] + for item in items: + try: + decoded = WynnSourceItem.FromString(item) + formatted_items.append(json_format.MessageToDict(decoded)) + except Exception: + continue # silently skip invalid items + return formatted_items + case ItemReturnType.NAME: + formatted_items = [] + for item in items: + try: + decoded = WynnSourceItem.FromString(item) + formatted_items.append(decoded.name) + except Exception: + continue # silently skip invalid items + return formatted_items diff --git a/app/schemas/response/response.py b/app/schemas/response/response.py index 0130fda..f00711b 100644 --- a/app/schemas/response/response.py +++ b/app/schemas/response/response.py @@ -38,7 +38,6 @@ def from_message(cls, message: str) -> "WCSResponse[dict]": return WCSResponse[dict]( data={"message": message}, code=ErrorCodes.OK, - timestamp=int(datetime.now(UTC).timestamp()), ) @classmethod @@ -49,7 +48,6 @@ def from_dict(cls, data: dict) -> "WCSResponse[dict]": return WCSResponse[dict]( data=data, code=ErrorCodes.OK, - timestamp=int(datetime.now(UTC).timestamp()), ) From b4e40c01bbd43c91d2dcd797e86c54cd1e352b27 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Tue, 24 Feb 2026 11:05:51 -0500 Subject: [PATCH 07/78] feat: add item mapping --- .gitmodules | 1 + app/core/router.py | 2 +- app/main.py | 9 ++- app/module/api/router.py | 21 ++++- app/module/api/schema.py | 22 +++++ app/module/api/service.py | 75 +++++++++++++++++ app/module/pool/config.py | 4 +- app/module/pool/schema.py | 16 ++-- app/module/pool/service.py | 36 +++++---- app/schemas/enums/item.py | 4 +- app/schemas/enums/tag.py | 1 + app/schemas/response/response.py | 2 +- pyproject.toml | 2 + schema | 2 +- uv.lock | 135 +++++++++++++++++++++++++++++++ 15 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 app/module/api/service.py diff --git a/.gitmodules b/.gitmodules index 05b9e68..25e60c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "schema"] path = schema url = git@github.com:WynnSource/schema.git + branch = main diff --git a/app/core/router.py b/app/core/router.py index a3856ce..49f5741 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -73,7 +73,7 @@ def __init__( responses[status_code]["description"] = doc["description"] description = self.add_description( - f"⌛ This response is cached for `{meta.cache.expire}` seconds.", description, endpoint + f"⌛ This response is cached for `{format_time(meta.cache.expire)}`.", description, endpoint ) # Add permission info to description and inject permission dependency diff --git a/app/main.py b/app/main.py index 9a78484..282835d 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.staticfiles import StaticFiles -from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference +from scalar_fastapi import AgentScalarConfig, OpenAPISource, get_scalar_api_reference from app.config import DB_CONFIG from app.core.db import RedisClient, close_db, init_db @@ -69,7 +69,12 @@ async def read_root() -> StatusResponse: async def scalar_ui(): return get_scalar_api_reference( title=f"{__NAME__} API Documentation", - openapi_url=app.openapi_url, + sources=[ + OpenAPISource( + slug="wynnsource-api", + url=app.openapi_url, + ) + ], agent=AgentScalarConfig(disabled=True), scalar_favicon_url="./static/favicon.ico", default_open_all_tags=True, diff --git a/app/module/api/router.py b/app/module/api/router.py index 9156ea4..5e0d491 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -2,24 +2,39 @@ from app.core import metadata from app.core.router import DocedAPIRoute -from app.module.api.schema import ValidationErrorResponse from app.module.manage.router import ManageRouter from app.module.market.router import MarketRouter from app.module.pool.router import PoolRouter +from app.schemas.enums import ApiTag from app.schemas.response import StatusResponse +from .schema import MappingResponse, MappingType, ValidationErrorResponse +from .service import MappingStorage + Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) Router.include_router(ManageRouter) Router.include_router(PoolRouter) Router.include_router(MarketRouter) -@Router.get("/test") +@Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC]) @metadata.rate_limit(10, 60) @metadata.cached(expire=60) @metadata.permission("api.test") async def test_endpoint() -> StatusResponse: """ - Test endpoint to verify API is working. + Test endpoint for various features like rate limiting, caching, and permissions. """ return StatusResponse() + + +@Router.get("/mappings/{mapping_type}", summary="Get ID Mappings", tags=[ApiTag.MISC]) +@metadata.rate_limit(10, 60) +@metadata.cached(expire=3600) # TTL 1 hour, updated by scheduled job +async def get_mappings(mapping_type: MappingType) -> MappingResponse: + """ + Get ID mappings for a given type. + """ + + storage = MappingStorage().get_instance() + return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore") diff --git a/app/module/api/schema.py b/app/module/api/schema.py index 8e62195..a868cd1 100644 --- a/app/module/api/schema.py +++ b/app/module/api/schema.py @@ -1,3 +1,6 @@ +from datetime import datetime +from enum import StrEnum + from pydantic import BaseModel from pydantic_core import ErrorDetails @@ -34,4 +37,23 @@ class ValidationErrorResponse(WCSResponse[list]): code: ErrorCodes = ErrorCodes.VALIDATION_ERROR +class MappingType(StrEnum): + IDENTIFICATION = "identification" + SHINY = "shiny" + + +class Mapping(BaseModel): + id: int + key: str + + +class MappingResponse(BaseModel): + """ + Response model for ID to name mappings. + """ + + lastUpdated: datetime + data: list[Mapping] + + __all__ = ["GenericExceptionResponse", "HTTPErrorResponse", "ValidationErrorResponse"] diff --git a/app/module/api/service.py b/app/module/api/service.py new file mode 100644 index 0000000..4f7a0c6 --- /dev/null +++ b/app/module/api/service.py @@ -0,0 +1,75 @@ +import httpx +import jsonschema +import orjson +from apscheduler.triggers.interval import IntervalTrigger + +from app.core.log import LOGGER +from app.core.scheduler import SCHEDULER + +from .schema import MappingType + +MAPPING_BASE_URL = "https://raw.githubusercontent.com/WynnSource/schema/refs/heads/main/mapping/" +JSON_FILE_EXTENSION = ".json" +SCHEMA_FILE_EXTENSION = ".schema.json" +SCHEMA_FIELD = "$schema" + + +class MappingStorage: + """ + Singleton storage class for ID to name mappings. + """ + + mappings: dict[MappingType, dict] + + def __init__(self): + self.mappings = {} + + def get_instance(self) -> "MappingStorage": + if not hasattr(self, "_instance"): + self._instance = MappingStorage() + return self._instance + + async def get_mapping(self, mapping_type: MappingType) -> dict: + if mapping_type in self.mappings: + return self.mappings[mapping_type] + await self.update_mapping(mapping_type) + return self.mappings.get(mapping_type, {}) + + async def update_mapping(self, mapping_type: MappingType): + url = f"{MAPPING_BASE_URL}{mapping_type.value}{JSON_FILE_EXTENSION}" + schema_url = f"{MAPPING_BASE_URL}{mapping_type.value}{SCHEMA_FILE_EXTENSION}" + async with httpx.AsyncClient() as client: + response = await client.get(url) + if response.status_code != 200: + LOGGER.error(f"Failed to fetch mapping for {mapping_type.value} from {url}: {response.status_code}") + return + schema_response = await client.get(schema_url) + if schema_response.status_code != 200: + LOGGER.error( + f"Failed to fetch schema for {mapping_type.value} from {schema_url}: {schema_response.status_code}" + ) + return + + mapping = orjson.loads(response.text) + schema = orjson.loads(schema_response.text) + + try: + jsonschema.validate(instance=mapping, schema=schema) + except jsonschema.ValidationError as e: + LOGGER.error(f"Mapping data for {mapping_type.value} failed schema validation: {e.message}") + return + + del mapping[SCHEMA_FIELD] # we don't need the schema reference as it's relative path in the repo + self.mappings[mapping_type] = mapping + + +@SCHEDULER.scheduled_job( + IntervalTrigger(minutes=60), id="mapping_update_trigger", name="Mapping Update", misfire_grace_time=300 +) +async def update_mapping(): + """ + Scheduled job to update ID to name mappings every hour. + """ + storage = MappingStorage().get_instance() + for mapping_type in MappingType: + await storage.update_mapping(mapping_type) diff --git a/app/module/pool/config.py b/app/module/pool/config.py index a52ed93..cdc6771 100644 --- a/app/module/pool/config.py +++ b/app/module/pool/config.py @@ -31,7 +31,7 @@ def get_rotation(self, time: datetime, shift: int = 0) -> PoolRotation: GLOBAL_RESET_ANCHOR = datetime(2026, 2, 20, 18, 0, 0, tzinfo=UTC) POOL_REFRESH_CONFIG = { - PoolType.ITEM: PoolConfig( + PoolType.LR_ITEM: PoolConfig( interval=timedelta(days=7), anchor=GLOBAL_RESET_ANCHOR, ), @@ -39,7 +39,7 @@ def get_rotation(self, time: datetime, shift: int = 0) -> PoolRotation: interval=timedelta(days=7), anchor=GLOBAL_RESET_ANCHOR, ), - PoolType.RAID_TOME: PoolConfig( + PoolType.RAID_ITEM: PoolConfig( interval=timedelta(days=7), anchor=GLOBAL_RESET_ANCHOR, ), diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index d5d107b..8dd2396 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -5,9 +5,9 @@ class PoolType(enum.StrEnum): - ITEM = "lr_item_pool" + LR_ITEM = "lr_item_pool" RAID_ASPECT = "raid_aspect_pool" - RAID_TOME = "raid_tome_pool" + RAID_ITEM = "raid_item_pool" class LootPoolRegion(enum.StrEnum): @@ -35,33 +35,33 @@ class RaidRegion(enum.StrEnum): VALID_REGIONS: dict[PoolType, type[enum.StrEnum]] = { - PoolType.ITEM: LootPoolRegion, + PoolType.LR_ITEM: LootPoolRegion, PoolType.RAID_ASPECT: RaidRegion, - PoolType.RAID_TOME: RaidRegion, + PoolType.RAID_ITEM: RaidRegion, } class PoolSubmissionSchema(BaseModel): pool_type: PoolType - region: LootPoolRegion | RaidRegion + region: str = Field(description="Region name, must be one of the valid regions for the pool type") page: int client_timestamp: datetime mod_version: str items: list[str] = Field( description="base64-encoded protobuf bytes for item", - json_schema_extra={"example": ["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]}, + examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], ) class PoolConsensusResponse(BaseModel): pool_type: PoolType - region: LootPoolRegion | RaidRegion + region: str = Field(description="Region name, must be one of the valid regions for the pool type") page_consensus: list["PageConsensus"] class PageConsensus(BaseModel): page: int = Field(description="The page number") items: list[str] | list[dict] = Field( description="Consensus item data for the page, format depends on item_return_type query parameter", - json_schema_extra={"example": ["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]}, + examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], ) confidence: float = Field(description="Confidence level, between 0 and 1") diff --git a/app/module/pool/service.py b/app/module/pool/service.py index e1dfb5b..3d3532b 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_session +from app.core.log import LOGGER from app.core.scheduler import SCHEDULER from app.core.score import Tier from app.core.security.model import User @@ -36,6 +37,10 @@ async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, us # we just want to make sure it can is a valid item items_decoded.append(decoded) except Exception: + LOGGER.debug( + f"Invalid item {item} in submission from user" + + f"{user.id} for pool {data.pool_type}:{data.region}:{data.page}" + ) continue # we silently skip invalid items if not items_decoded: @@ -57,7 +62,7 @@ async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, us or abs(pool.rotation_end - data.client_timestamp) < FUZZY_WINDOW ) submission = PoolSubmission( - user=user, + user_id=user.id, client_timestamp=data.client_timestamp, item_data=items_decoded, weight=calculate_submission_weight(user, fuzzy), @@ -69,14 +74,12 @@ async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, us existingSubmission = await submissionRepo.get_user_submission_for_rotation(user.id, pool.id) if existingSubmission is not None: - # if the new submission is different from the existing one, we replace it - # the existing one still stay in the user's submission history - if existingSubmission.item_data != submission.item_data: - existingSubmission.item_data = submission.item_data - existingSubmission.client_timestamp = submission.client_timestamp - existingSubmission.weight = submission.weight - existingSubmission.mod_version = submission.mod_version - pool.needs_recalc = True + # we update the existing submission + existingSubmission.item_data = submission.item_data + existingSubmission.client_timestamp = submission.client_timestamp + existingSubmission.weight = submission.weight + existingSubmission.mod_version = submission.mod_version + pool.needs_recalc = True else: submission.rotation = pool pool.submission_count += 1 @@ -103,7 +106,7 @@ def calculate_submission_weight(user: User, fuzzy: bool = False) -> float: @SCHEDULER.scheduled_job( IntervalTrigger(minutes=20), id="compute_pool_consensus", - misfire_grace_time=60, # Allow a 1-minute grace period for missed executions + misfire_grace_time=60, coalesce=True, # Coalesce multiple missed executions into one ) async def compute_pool_consensus(): @@ -126,11 +129,16 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): for pool in active_pools: submissions = pool.submissions - item_weights: dict[bytes, float] = defaultdict(float) + item_weights: dict[tuple[bytes, int], float] = defaultdict(float) for submission in submissions: + local_counts = defaultdict(int) + for item_data in submission.item_data: - item_weights[item_data] += submission.weight + local_counts[item_data] += 1 + occurrence = local_counts[item_data] + + item_weights[(item_data, occurrence)] += submission.weight if not item_weights: pool.consensus_data = [] @@ -141,7 +149,7 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): highest_weight = max(item_weights.values()) if highest_weight <= 0: - pool.consensus_data = list(item_weights.keys()) + pool.consensus_data = [] pool.confidence = 0.0 pool.needs_recalc = False continue @@ -151,7 +159,7 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): consensus_items = [] consensus_weights = [] - for item, weight in item_weights.items(): + for (item, _), weight in item_weights.items(): if weight >= threshold: consensus_items.append(item) consensus_weights.append(weight) diff --git a/app/schemas/enums/item.py b/app/schemas/enums/item.py index 2c47c17..66349af 100644 --- a/app/schemas/enums/item.py +++ b/app/schemas/enums/item.py @@ -9,7 +9,7 @@ class ItemReturnType(StrEnum): B64 = "b64" JSON = "json" - NAME = "name" + NAME_ONLY = "name_only" def format_items(self, items: list[bytes]) -> list[str] | list[dict]: match self: @@ -24,7 +24,7 @@ def format_items(self, items: list[bytes]) -> list[str] | list[dict]: except Exception: continue # silently skip invalid items return formatted_items - case ItemReturnType.NAME: + case ItemReturnType.NAME_ONLY: formatted_items = [] for item in items: try: diff --git a/app/schemas/enums/tag.py b/app/schemas/enums/tag.py index 51c61c8..bb0b3d5 100644 --- a/app/schemas/enums/tag.py +++ b/app/schemas/enums/tag.py @@ -5,3 +5,4 @@ class ApiTag(StrEnum): MANAGEMENT = "management" POOL = "pool" MARKET = "market" + MISC = "misc" diff --git a/app/schemas/response/response.py b/app/schemas/response/response.py index f00711b..3991bba 100644 --- a/app/schemas/response/response.py +++ b/app/schemas/response/response.py @@ -19,7 +19,7 @@ class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel): timestamp: int = Field( default_factory=lambda: int(datetime.now(UTC).timestamp()), frozen=True, - json_schema_extra={"example": int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())}, + examples=[int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())], ) version: int = Field(default=__REVISION__, frozen=True) diff --git a/pyproject.toml b/pyproject.toml index 7a63686..78f0367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "apscheduler>=3.11.0", "asyncpg>=0.30.0", "fastapi[standard]>=0.115.12", + "httpx>=0.28.1", + "jsonschema>=4.26.0", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings>=2.10.1", diff --git a/schema b/schema index 2831e80..c3549ce 160000 --- a/schema +++ b/schema @@ -1 +1 @@ -Subproject commit 2831e8041dd4ec57bc136a4f98c74a0cf5d574ac +Subproject commit c3549ce69a9417355d96d542f4e2f9bfa18f4cf2 diff --git a/uv.lock b/uv.lock index 837cd2a..1059a2d 100644 --- a/uv.lock +++ b/uv.lock @@ -110,6 +110,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "basedpyright" version = "1.38.1" @@ -541,6 +550,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -995,6 +1031,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1090,6 +1140,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + [[package]] name = "ruff" version = "0.15.2" @@ -1480,6 +1611,8 @@ dependencies = [ { name = "apscheduler" }, { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, + { name = "jsonschema" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic-settings" }, @@ -1506,6 +1639,8 @@ requires-dist = [ { name = "apscheduler", specifier = ">=3.11.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonschema", specifier = ">=4.26.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, From 788219f7a45de5bd26901ab828111f69463b81a7 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 17:36:50 -0500 Subject: [PATCH 08/78] feat: add beta item submission --- app/config/log.py | 7 ++- app/core/db/models.py | 6 +- app/core/security/auth.py | 34 ++++-------- app/module/api/router.py | 27 +++++++-- app/module/api/schema.py | 7 ++- app/module/api/service.py | 35 ++++++++++-- app/module/beta/model.py | 35 ++++++++++++ app/module/beta/router.py | 39 +++++++++++++ app/module/beta/schema.py | 20 +++++++ app/module/beta/service.py | 30 ++++++++++ app/module/pool/config.py | 55 ++++++++++++------- app/module/pool/model.py | 28 +++++++--- app/module/pool/router.py | 35 ++++++++++-- app/module/pool/schema.py | 13 ++++- app/module/pool/service.py | 37 +++++++------ app/schemas/enums/item.py | 35 ++++++------ app/schemas/enums/tag.py | 1 + ...cca31bfed_init.py => b6251978ce59_init.py} | 21 +++++-- pyproject.toml | 3 +- 19 files changed, 355 insertions(+), 113 deletions(-) create mode 100644 app/module/beta/model.py create mode 100644 app/module/beta/router.py create mode 100644 app/module/beta/schema.py create mode 100644 app/module/beta/service.py rename migration/versions/{938cca31bfed_init.py => b6251978ce59_init.py} (84%) diff --git a/app/config/log.py b/app/config/log.py index 763d3d8..a8a4cb2 100644 --- a/app/config/log.py +++ b/app/config/log.py @@ -7,7 +7,12 @@ class LoggerConfig(BaseSettings): """ level: str = "DEBUG" - format: str = "{time:YY-MM-DD HH:mm:ss} [{level: <8}] {name}:{line} | {message}" + format: str = ( + "{time:YY-MM-DD HH:mm:ss}" + + "[{level: <8}]" + + "{name}:{line}" + + " | {message}" + ) LOG_CONFIG = LoggerConfig() diff --git a/app/core/db/models.py b/app/core/db/models.py index 195de08..0a6cf3b 100644 --- a/app/core/db/models.py +++ b/app/core/db/models.py @@ -1,6 +1,10 @@ -"""This is a file for alembic to load all models for autogeneration. It should not be imported anywhere else.""" +""" +This is a file for alembic to load all models for autogeneration. +It should not be imported anywhere else. +""" import app.core.security.model +import app.module.beta.model import app.module.pool.model # noqa: F401 __all__ = [] diff --git a/app/core/security/auth.py b/app/core/security/auth.py index 45946e2..2be9ec2 100644 --- a/app/core/security/auth.py +++ b/app/core/security/auth.py @@ -38,7 +38,8 @@ def has_permission(required: str | set[str] | None, user: set[str]) -> bool: for req in required: # Check for exact match or wildcard match in user permissions if not any( - _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req for user_perm in user + _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req + for user_perm in user ): return False return True @@ -81,7 +82,9 @@ async def get_user( user = await userRepo.get_user_by_token(api_key) if not user: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token" + ) assert request.client is not None @@ -89,13 +92,17 @@ async def get_user( LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header") elif x_real_ip is None: LOGGER.warning(f"Token {user.token} used without X-Real-IP header") - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address" + ) await userRepo.update_user_ip(user, x_real_ip or request.client.host) await verify_user(user) - request.state.user_id = user.id # Store user ID in request state for later use (e.g. rate limiting) + request.state.user_id = ( + user.id + ) # Store user ID in request state for later use (e.g. rate limiting) return user @@ -115,25 +122,6 @@ async def dependency(user: UserDep) -> User: return dependency -# Used before custom APIRoute is implemented, kept for reference and potential future use -# async def require_permission(request: Request, user: User = Depends(get_user), session=Depends(get_session)) -> User: - -# # Fetch the required permissions from the endpoint metadata -# endpoint = request.scope.get("endpoint") -# metadata = getattr(endpoint, "__metadata__", None) -# if not isinstance(metadata, EndpointMetadata): -# LOGGER.error(f"Endpoint {endpoint} does not have metadata or is not an EndpointMetadata instance") -# raise HTTPException(status_code=500, detail="Internal Server Error: Missing endpoint metadata") -# required_perms = metadata.permission - -# if not has_permission(required_perms, set(user.token.permissions)): -# LOGGER.warning(f"User {user.token.token} does not have required permissions: {required_perms}") -# LOGGER.debug(f"User {user.token.token} permissions: {user.token.permissions}") -# raise HTTPException(status_code=403, detail="Insufficient permissions") - -# return user - - @lru_cache(maxsize=1024) def hash_token(token_str: str) -> str: return sha256(token_str.encode()).hexdigest() diff --git a/app/module/api/router.py b/app/module/api/router.py index 5e0d491..886e7e2 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -2,19 +2,23 @@ from app.core import metadata from app.core.router import DocedAPIRoute +from app.module.beta.router import BetaRouter from app.module.manage.router import ManageRouter from app.module.market.router import MarketRouter from app.module.pool.router import PoolRouter -from app.schemas.enums import ApiTag -from app.schemas.response import StatusResponse +from app.schemas.enums import ApiTag, ItemReturnType +from app.schemas.response import StatusResponse, WCSResponse -from .schema import MappingResponse, MappingType, ValidationErrorResponse -from .service import MappingStorage +from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse +from .service import MappingStorage, generate_random_item -Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) +Router = APIRouter( + route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}} +) Router.include_router(ManageRouter) Router.include_router(PoolRouter) Router.include_router(MarketRouter) +Router.include_router(BetaRouter) @Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC]) @@ -38,3 +42,16 @@ async def get_mappings(mapping_type: MappingType) -> MappingResponse: storage = MappingStorage().get_instance() return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore") + + +@Router.get("/item/random", summary="Get Random Item", tags=[ApiTag.MISC]) +@metadata.rate_limit(10, 60) +async def get_random_item( + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[RandomItemResponse]: + """ + Get a random item for testing purposes. + """ + return WCSResponse( + data=RandomItemResponse(item=item_return_type.format_item(generate_random_item())) + ) diff --git a/app/module/api/schema.py b/app/module/api/schema.py index a868cd1..c139a4a 100644 --- a/app/module/api/schema.py +++ b/app/module/api/schema.py @@ -56,4 +56,9 @@ class MappingResponse(BaseModel): data: list[Mapping] -__all__ = ["GenericExceptionResponse", "HTTPErrorResponse", "ValidationErrorResponse"] +class RandomItemResponse(BaseModel): + """ + Response model for random item endpoint. + """ + + item: str | dict diff --git a/app/module/api/service.py b/app/module/api/service.py index 4f7a0c6..26e4129 100644 --- a/app/module/api/service.py +++ b/app/module/api/service.py @@ -1,3 +1,5 @@ +import random + import httpx import jsonschema import orjson @@ -5,6 +7,8 @@ from app.core.log import LOGGER from app.core.scheduler import SCHEDULER +from wynnsource import WynnSourceItem +from wynnsource.common.enums_pb2 import RARITY_CRAFTED from .schema import MappingType @@ -41,12 +45,16 @@ async def update_mapping(self, mapping_type: MappingType): async with httpx.AsyncClient() as client: response = await client.get(url) if response.status_code != 200: - LOGGER.error(f"Failed to fetch mapping for {mapping_type.value} from {url}: {response.status_code}") + LOGGER.error( + f"Failed to fetch mapping for {mapping_type.value} " + + "from {url}: {response.status_code}" + ) return schema_response = await client.get(schema_url) if schema_response.status_code != 200: LOGGER.error( - f"Failed to fetch schema for {mapping_type.value} from {schema_url}: {schema_response.status_code}" + f"Failed to fetch schema for {mapping_type.value} " + + "from {schema_url}: {schema_response.status_code}" ) return @@ -56,15 +64,22 @@ async def update_mapping(self, mapping_type: MappingType): try: jsonschema.validate(instance=mapping, schema=schema) except jsonschema.ValidationError as e: - LOGGER.error(f"Mapping data for {mapping_type.value} failed schema validation: {e.message}") + LOGGER.error( + f"Mapping data for {mapping_type.value} failed schema validation: {e.message}" + ) return - del mapping[SCHEMA_FIELD] # we don't need the schema reference as it's relative path in the repo + del mapping[ + SCHEMA_FIELD + ] # we don't need the schema reference as it's relative path in the repo self.mappings[mapping_type] = mapping @SCHEDULER.scheduled_job( - IntervalTrigger(minutes=60), id="mapping_update_trigger", name="Mapping Update", misfire_grace_time=300 + IntervalTrigger(minutes=60), + id="mapping_update_trigger", + name="Mapping Update", + misfire_grace_time=300, ) async def update_mapping(): """ @@ -73,3 +88,13 @@ async def update_mapping(): storage = MappingStorage().get_instance() for mapping_type in MappingType: await storage.update_mapping(mapping_type) + + +def generate_random_item() -> bytes: + """ + Generate a random item for testing purposes. + """ + id = random.randint(1, 10000) + return WynnSourceItem( + name=f"Test Item {id}", level=id % 100, rarity=RARITY_CRAFTED + ).SerializeToString() diff --git a/app/module/beta/model.py b/app/module/beta/model.py new file mode 100644 index 0000000..abb8031 --- /dev/null +++ b/app/module/beta/model.py @@ -0,0 +1,35 @@ +from collections.abc import Sequence + +from sqlalchemy import LargeBinary, String, UniqueConstraint, select +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, BaseRepository +from wynnsource import WynnSourceItem + + +class BetaItem(Base): + __tablename__ = "beta_items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + item: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + __table_args__ = (UniqueConstraint("name", name="uq_beta_item_name"),) + + +class BetaItemRepository(BaseRepository): + async def add_item(self, item: WynnSourceItem) -> BetaItem: + existing = ( + await self.session.execute(select(BetaItem).where(BetaItem.name == item.name)) + ).scalar_one_or_none() + if existing: + raise ValueError("Item already exists in beta list") + beta_item = BetaItem(name=item.name, item=item.SerializeToString()) + self.session.add(beta_item) + await self.session.flush() + await self.session.refresh(beta_item) + return beta_item + + async def list_items(self) -> Sequence[BetaItem]: + result = await self.session.execute(select(BetaItem)) + return result.scalars().all() diff --git a/app/module/beta/router.py b/app/module/beta/router.py new file mode 100644 index 0000000..04aed6e --- /dev/null +++ b/app/module/beta/router.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter + +from app.core import metadata +from app.core.db import SessionDep +from app.core.router import DocedAPIRoute +from app.module.beta.schema import BetaItemListResponse, NewItemSubmission +from app.module.beta.service import get_beta_items, handle_item_submission +from app.schemas.enums import ItemReturnType +from app.schemas.enums.tag import ApiTag +from app.schemas.response import EmptyResponse, WCSResponse + +BetaRouter = APIRouter(route_class=DocedAPIRoute, prefix="/beta", tags=[ApiTag.BETA]) + + +@BetaRouter.get("/items", summary="List Beta Items") +@metadata.rate_limit(limit=10, period=60) +@metadata.cached(expire=300) +async def list_beta_items( + session: SessionDep, + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[BetaItemListResponse]: + """ + List all items in the beta list. + """ + return WCSResponse( + data=BetaItemListResponse( + items=item_return_type.format_items(await get_beta_items(session)) + ) + ) + + +@BetaRouter.post("/items", summary="Submit new Beta Item") +@metadata.rate_limit(limit=30, period=60) +async def submit_beta_item(items: NewItemSubmission, session: SessionDep) -> EmptyResponse: + """ + Submit new items to be added to the beta list. + """ + await handle_item_submission(items, session) + return EmptyResponse() diff --git a/app/module/beta/schema.py b/app/module/beta/schema.py new file mode 100644 index 0000000..45c9c22 --- /dev/null +++ b/app/module/beta/schema.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class NewItemSubmission(BaseModel): + client_timestamp: datetime + mod_version: str + items: list[str] = Field( + description="List of base64-encoded protobuf bytes for items", + examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], + ) + + +class BetaItemListResponse(BaseModel): + items: list[str] | list[dict] = Field( + description="List of items in the beta list, " + + "format depends on item_return_type query parameter", + examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], + ) diff --git a/app/module/beta/service.py b/app/module/beta/service.py new file mode 100644 index 0000000..601d5b3 --- /dev/null +++ b/app/module/beta/service.py @@ -0,0 +1,30 @@ +from base64 import b64decode + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.log import LOGGER +from wynnsource import WynnSourceItem + +from .model import BetaItemRepository +from .schema import NewItemSubmission + + +async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: + itemRepo = BetaItemRepository(session) + succeeds = 0 + for item in submission.items: + try: + await itemRepo.add_item(WynnSourceItem.FromString(b64decode(item))) + succeeds += 1 + except Exception as e: + LOGGER.debug(f"Failed to add item from submission: {item}, error: {e}") + # Silently ignore duplicate item submissions or invalid items + pass + + LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta submission") + + +async def get_beta_items(session: AsyncSession) -> list[bytes]: + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.list_items() + return [item.item for item in beta_items] diff --git a/app/module/pool/config.py b/app/module/pool/config.py index cdc6771..28cfa79 100644 --- a/app/module/pool/config.py +++ b/app/module/pool/config.py @@ -1,10 +1,13 @@ from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo from app.core.score import Tier from .schema import PoolType +SERVER_TZ = ZoneInfo("America/New_York") + @dataclass class PoolRotation: @@ -14,39 +17,51 @@ class PoolRotation: @dataclass class PoolConfig: - interval: timedelta - anchor: datetime + weekday: int + winter_hour: int + summer_hour: int + + def _get_exact_reset_time(self, d: date) -> datetime: + noon = datetime.combine(d, datetime.min.time().replace(hour=12), tzinfo=SERVER_TZ) + is_dst = bool(noon.dst()) + hour = self.summer_hour if is_dst else self.winter_hour + return datetime.combine(d, datetime.min.time().replace(hour=hour), tzinfo=SERVER_TZ) def get_rotation(self, time: datetime, shift: int = 0) -> PoolRotation: if time.tzinfo is None: raise ValueError("The 'time' parameter must be timezone-aware.") - base_cycles = (time - self.anchor) // self.interval - target_cycles = base_cycles + shift - start = self.anchor + (self.interval * target_cycles) + local_time = time.astimezone(SERVER_TZ) + + days_since_target = (local_time.weekday() - self.weekday) % 7 + candidate_date = local_time.date() - timedelta(days=days_since_target) + + current_reset = self._get_exact_reset_time(candidate_date) + + if local_time < current_reset: + candidate_date -= timedelta(days=7) + current_reset = self._get_exact_reset_time(candidate_date) - return PoolRotation(start=start, end=start + self.interval) + if shift != 0: + candidate_date += timedelta(days=7 * shift) + current_reset = self._get_exact_reset_time(candidate_date) + next_reset = self._get_exact_reset_time(candidate_date + timedelta(days=7)) + + return PoolRotation(start=current_reset, end=next_reset) -GLOBAL_RESET_ANCHOR = datetime(2026, 2, 20, 18, 0, 0, tzinfo=UTC) POOL_REFRESH_CONFIG = { - PoolType.LR_ITEM: PoolConfig( - interval=timedelta(days=7), - anchor=GLOBAL_RESET_ANCHOR, - ), - PoolType.RAID_ASPECT: PoolConfig( - interval=timedelta(days=7), - anchor=GLOBAL_RESET_ANCHOR, - ), + PoolType.LR_ITEM: PoolConfig(weekday=4, winter_hour=15, summer_hour=14), + PoolType.RAID_ASPECT: PoolConfig(weekday=4, winter_hour=14, summer_hour=13), PoolType.RAID_ITEM: PoolConfig( - interval=timedelta(days=7), - anchor=GLOBAL_RESET_ANCHOR, + weekday=4, + winter_hour=15, + summer_hour=14, ), } - -FUZZY_WINDOW = timedelta(minutes=10) +FUZZY_WINDOW = timedelta(minutes=90) WEIGHT_MAP: dict[Tier | str, float] = { Tier.Rookie: 0.1, diff --git a/app/module/pool/model.py b/app/module/pool/model.py index 7c76976..70470b2 100644 --- a/app/module/pool/model.py +++ b/app/module/pool/model.py @@ -36,10 +36,14 @@ class Pool(Base): rotation_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) rotation_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - consensus_data: Mapped[list[bytes]] = mapped_column(ARRAY(LargeBinary), nullable=False, default=[]) - last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + consensus_data: Mapped[list[bytes]] = mapped_column( + ARRAY(LargeBinary), nullable=False, default=[] + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_updated: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=datetime.now + ) confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) - submission_count: Mapped[int] = mapped_column(Integer, default=0) needs_recalc: Mapped[bool] = mapped_column(Boolean, default=True) @@ -49,7 +53,11 @@ class Pool(Base): cascade="save-update, merge", ) - __table_args__ = (UniqueConstraint("pool_type", "region", "page", "rotation_start", name="uq_pool_rotation_key"),) + __table_args__ = ( + UniqueConstraint( + "pool_type", "region", "page", "rotation_start", name="uq_pool_rotation_key" + ), + ) class PoolSubmission(Base): @@ -78,7 +86,9 @@ class PoolSubmission(Base): class PoolRepository(BaseRepository): - async def get_by_key(self, pool_type: PoolType, region: str, page: int, rotation_start: datetime) -> Pool | None: + async def get_by_key( + self, pool_type: PoolType, region: str, page: int, rotation_start: datetime + ) -> Pool | None: query = select(Pool).where( Pool.pool_type == pool_type.value, Pool.region == region, @@ -126,7 +136,9 @@ async def delete(self, pool: Pool) -> None: await self.session.delete(pool) await self.session.flush() - async def get_or_create_pool(self, pool_type: PoolType, region: str, page: int, rotation: PoolRotation) -> Pool: + async def get_or_create_pool( + self, pool_type: PoolType, region: str, page: int, rotation: PoolRotation + ) -> Pool: existing_pool = await self.get_by_key( pool_type=pool_type, region=region, @@ -166,7 +178,9 @@ async def list_submissions_for_rotation(self, rotation_id: int) -> list[PoolSubm result = await self.session.execute(query) return list(result.scalars().all()) - async def get_user_submission_for_rotation(self, user_id: int, rotation_id: int) -> PoolSubmission | None: + async def get_user_submission_for_rotation( + self, user_id: int, rotation_id: int + ) -> PoolSubmission | None: query = select(PoolSubmission).where( PoolSubmission.user_id == user_id, PoolSubmission.rotation_id == rotation_id, diff --git a/app/module/pool/router.py b/app/module/pool/router.py index b03dc07..30ac427 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -12,7 +12,13 @@ from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse from .config import POOL_REFRESH_CONFIG -from .schema import LootPoolRegion, PoolConsensusResponse, PoolSubmissionSchema, PoolType, RaidRegion +from .schema import ( + LootPoolRegion, + PoolConsensusResponse, + PoolSubmissionSchema, + PoolType, + RaidRegion, +) from .service import compute_pool_consensus, get_pool_consensus from .service import submit_pool_data as svc_submit_pool_data @@ -27,7 +33,8 @@ async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> E """ for submission in data: try: - # We want to process each submission in a different sessions to avoid one bad submission breaking others + # We want to process each submission + # in a different sessions to avoid one bad submission breaking others async with get_session() as session: await svc_submit_pool_data(session, submission, user) except ValueError: @@ -49,15 +56,24 @@ async def get_pools_by_type_and_region( Get pools by type and region. """ try: + rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation( + datetime.datetime.now(tz=datetime.UTC) + ) consensus_by_page = await get_pool_consensus( session, pool_type, region, - rotation_start=POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)).start, + rotation_start=rotation.start, ) if not consensus_by_page: - data = PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=[]) + data = PoolConsensusResponse( + pool_type=pool_type, + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + page_consensus=[], + ) else: page_consensus = [] for page, (items, confidence) in consensus_by_page.items(): @@ -69,7 +85,13 @@ async def get_pools_by_type_and_region( ) ) - data = PoolConsensusResponse(pool_type=pool_type, region=region, page_consensus=page_consensus) + data = PoolConsensusResponse( + pool_type=pool_type, + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + page_consensus=page_consensus, + ) except ValueError as e: raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) @@ -81,7 +103,8 @@ async def get_pools_by_type_and_region( @metadata.permission("pool.recalc") async def recalculate_pools() -> EmptyResponse: """ - Force recalculate pool consensus. This is useful for admins to fix any issues with the consensus calculation. + Force recalculate pool consensus. + This is useful for admins to fix any issues with the consensus calculation. """ await compute_pool_consensus() diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index 8dd2396..a01b541 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -43,7 +43,9 @@ class RaidRegion(enum.StrEnum): class PoolSubmissionSchema(BaseModel): pool_type: PoolType - region: str = Field(description="Region name, must be one of the valid regions for the pool type") + region: str = Field( + description="Region name, must be one of the valid regions for the pool type" + ) page: int client_timestamp: datetime mod_version: str @@ -55,13 +57,18 @@ class PoolSubmissionSchema(BaseModel): class PoolConsensusResponse(BaseModel): pool_type: PoolType - region: str = Field(description="Region name, must be one of the valid regions for the pool type") + region: str = Field( + description="Region name, must be one of the valid regions for the pool type" + ) + rotation_start: datetime + rotation_end: datetime page_consensus: list["PageConsensus"] class PageConsensus(BaseModel): page: int = Field(description="The page number") items: list[str] | list[dict] = Field( - description="Consensus item data for the page, format depends on item_return_type query parameter", + description="Consensus item data for the page, " + + "format depends on item_return_type query parameter", examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], ) confidence: float = Field(description="Confidence level, between 0 and 1") diff --git a/app/module/pool/service.py b/app/module/pool/service.py index 3d3532b..dff1055 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -74,22 +74,18 @@ async def submit_pool_data(session: AsyncSession, data: PoolSubmissionSchema, us existingSubmission = await submissionRepo.get_user_submission_for_rotation(user.id, pool.id) if existingSubmission is not None: - # we update the existing submission - existingSubmission.item_data = submission.item_data - existingSubmission.client_timestamp = submission.client_timestamp - existingSubmission.weight = submission.weight - existingSubmission.mod_version = submission.mod_version - pool.needs_recalc = True - else: - submission.rotation = pool - pool.submission_count += 1 - pool.needs_recalc = True - await submissionRepo.save(submission) + # we delete it infavor of the new submission + await submissionRepo.delete(existingSubmission) + + submission.rotation = pool + pool.needs_recalc = True + await submissionRepo.save(submission) def calculate_submission_weight(user: User, fuzzy: bool = False) -> float: if user.score < 0: - # For users with negative scores, we directly map (-10000, 0) to (0.01, 0.1) on a logarithmic scale + # For users with negative scores, + # we directly map (-10000, 0) to (0.01, 0.1) on a logarithmic scale return max(0.0001, min(0.1, 0.1 * (10 ** (user.score / 10000)))) else: tier = Tier.get_by_score(user.score) @@ -120,7 +116,9 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): poolRepo = PoolRepository(session) active_pools = await poolRepo.list_pools( pool_type=pool_type, - rotation_start=POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)).start, + rotation_start=POOL_REFRESH_CONFIG[pool_type] + .get_rotation(datetime.datetime.now(tz=datetime.UTC)) + .start, needs_recalc=True, ) # The active pools should only differ in (region, page) @@ -166,7 +164,9 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): pool.consensus_data = consensus_items confidence = ( - sum(consensus_weights) / (highest_weight * len(consensus_weights)) if consensus_weights else 0.0 + sum(consensus_weights) / (highest_weight * len(consensus_weights)) + if consensus_weights + else 0.0 ) pool.confidence = round(confidence, 4) pool.needs_recalc = False @@ -176,7 +176,10 @@ async def compute_pool_consensus_for_pool(pool_type: PoolType): async def get_pool_consensus( - session: AsyncSession, pool_type: PoolType, region: LootPoolRegion | RaidRegion, rotation_start: datetime.datetime + session: AsyncSession, + pool_type: PoolType, + region: LootPoolRegion | RaidRegion, + rotation_start: datetime.datetime, ) -> ConsensusByPage: if region not in VALID_REGIONS[pool_type]: @@ -184,7 +187,9 @@ async def get_pool_consensus( poolRepo = PoolRepository(session) - pool = await poolRepo.list_pools(pool_type=pool_type, region=region, rotation_start=rotation_start, order_by="page") + pool = await poolRepo.list_pools( + pool_type=pool_type, region=region, rotation_start=rotation_start, order_by="page" + ) if not pool: return {} diff --git a/app/schemas/enums/item.py b/app/schemas/enums/item.py index 66349af..fbf069d 100644 --- a/app/schemas/enums/item.py +++ b/app/schemas/enums/item.py @@ -1,10 +1,13 @@ from base64 import b64encode from enum import StrEnum +from typing import Any from google.protobuf import json_format from wynnsource import WynnSourceItem +INVALID_PLACEHOLDER = "INVALID_ITEM" + class ItemReturnType(StrEnum): B64 = "b64" @@ -12,24 +15,22 @@ class ItemReturnType(StrEnum): NAME_ONLY = "name_only" def format_items(self, items: list[bytes]) -> list[str] | list[dict]: + formatted: list[Any] = [self.format_item(item) for item in items] + return formatted + + def format_item(self, item: bytes) -> str | dict: match self: case ItemReturnType.B64: - return [b64encode(item).decode() for item in items] + return b64encode(item).decode() case ItemReturnType.JSON: - formatted_items = [] - for item in items: - try: - decoded = WynnSourceItem.FromString(item) - formatted_items.append(json_format.MessageToDict(decoded)) - except Exception: - continue # silently skip invalid items - return formatted_items + try: + decoded = WynnSourceItem.FromString(item) + return json_format.MessageToDict(decoded) + except Exception: + return INVALID_PLACEHOLDER # return a placeholder for invalid items case ItemReturnType.NAME_ONLY: - formatted_items = [] - for item in items: - try: - decoded = WynnSourceItem.FromString(item) - formatted_items.append(decoded.name) - except Exception: - continue # silently skip invalid items - return formatted_items + try: + decoded = WynnSourceItem.FromString(item) + return decoded.name + except Exception: + return INVALID_PLACEHOLDER # return a placeholder for invalid items diff --git a/app/schemas/enums/tag.py b/app/schemas/enums/tag.py index bb0b3d5..abfcdc6 100644 --- a/app/schemas/enums/tag.py +++ b/app/schemas/enums/tag.py @@ -5,4 +5,5 @@ class ApiTag(StrEnum): MANAGEMENT = "management" POOL = "pool" MARKET = "market" + BETA = "beta" MISC = "misc" diff --git a/migration/versions/938cca31bfed_init.py b/migration/versions/b6251978ce59_init.py similarity index 84% rename from migration/versions/938cca31bfed_init.py rename to migration/versions/b6251978ce59_init.py index 0df9655..078e612 100644 --- a/migration/versions/938cca31bfed_init.py +++ b/migration/versions/b6251978ce59_init.py @@ -1,8 +1,8 @@ """init -Revision ID: 938cca31bfed +Revision ID: b6251978ce59 Revises: -Create Date: 2026-02-21 19:35:10.056243 +Create Date: 2026-02-27 17:35:06.317999 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. -revision: str = '938cca31bfed' +revision: str = 'b6251978ce59' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,6 +21,14 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.create_table('beta_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('item', sa.LargeBinary(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + sa.UniqueConstraint('name', name='uq_beta_item_name') + ) op.create_table('pools', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('pool_type', sa.String(length=50), nullable=False), @@ -28,10 +36,10 @@ def upgrade() -> None: sa.Column('page', sa.Integer(), nullable=False), sa.Column('rotation_start', sa.DateTime(timezone=True), nullable=False), sa.Column('rotation_end', sa.DateTime(timezone=True), nullable=False), - sa.Column('consensus_data', sa.ARRAY(sa.LargeBinary()), nullable=True), + sa.Column('consensus_data', sa.ARRAY(sa.LargeBinary()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('confidence', sa.Float(), nullable=True), - sa.Column('submission_count', sa.Integer(), nullable=False), + sa.Column('confidence', sa.Float(), nullable=False), sa.Column('needs_recalc', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('pool_type', 'region', 'page', 'rotation_start', name='uq_pool_rotation_key') @@ -77,4 +85,5 @@ def downgrade() -> None: op.drop_index(op.f('ix_users_token'), table_name='users') op.drop_table('users') op.drop_table('pools') + op.drop_table('beta_items') # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 78f0367..20b37d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ test = ["pytest>=8.3.5", "pytest-cov>=6.1.1", "pytest-xdist>=3.6.1"] line-ending = "lf" [tool.ruff] -line-length = 120 +line-length = 100 target-version = "py312" src = ["app"] exclude = ["generated"] @@ -85,6 +85,5 @@ wynnsource-proto = { workspace = true, editable = true } [tool.basedpyright] typeCheckingMode = "basic" -diagnosticMode = "workspace" include = ["app", "tests/*"] exclude = ["generated"] From 0b88736f11cd7915005cd377046834b23d0e9982 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 18:54:41 -0500 Subject: [PATCH 09/78] feat: implement CI/CD pipeline, enhance Docker setup, and update deployment configurations --- .github/workflows/deploy.yaml | 46 +++++++++++++++++++++++++++++++ Dockerfile | 15 +++++++--- README.md | 10 +++++++ app/config/db.py | 2 +- app/config/user.py | 4 ++- app/main.py | 8 ++++++ deploy/configmap.yaml | 7 +++++ deploy/deployment.yaml | 45 ++++++++++++++++++++++++++++++ deploy/kustomization.yaml | 13 +++++++++ deploy/migration-job.yaml | 19 +++++++++++++ deploy/postgres.yaml | 15 ++++++++++ deploy/redis.yaml | 17 ++++++++++++ deploy/service.yaml | 15 ++++++++++ docker-compose.yaml | 52 +++++++++++++++++++++++++++-------- schema | 2 +- 15 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/deploy.yaml create mode 100644 deploy/configmap.yaml create mode 100644 deploy/deployment.yaml create mode 100644 deploy/kustomization.yaml create mode 100644 deploy/migration-job.yaml create mode 100644 deploy/postgres.yaml create mode 100644 deploy/redis.yaml create mode 100644 deploy/service.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..71bf91a --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,46 @@ +name: Build & Deploy +on: + workflow_dispatch: + push: + tags: + - "v*.*.*" +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/wynnsource-server +jobs: + build-and-update: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Update image tag in deploy/ + run: | + cd deploy + kustomize edit set image \ + ghcr.io/${{ env.IMAGE_NAME }}=ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }} + - name: Commit updated kustomization + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add deploy/kustomization.yaml + git diff --cached --quiet || git commit -m "chore: update image to ${{ github.sha }}" && git push diff --git a/Dockerfile b/Dockerfile index 1cffdba..8d311c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,17 @@ +FROM bufbuild/buf:1.66.0 AS buf_installer + # Use a Python image with uv pre-installed FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim # Install the project into `/app` WORKDIR /app +COPY . /app + +# Copy the buf binary from the buf_installer stage and make it executable +COPY --from=buf_installer /usr/local/bin/buf /usr/local/bin/buf + +RUN chmod +x /usr/local/bin/buf +RUN buf generate --template buf.gen.yaml # Enable bytecode compilation ENV UV_COMPILE_BYTECODE=1 @@ -16,14 +25,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --locked --no-install-project --no-dev -# Then, add the rest of the project source code and install it -# Installing separately from its dependencies allows optimal layer caching -COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" -# Run the FastAPI application by default +# Run the FastAPI application +EXPOSE 8000 CMD fastapi run \ No newline at end of file diff --git a/README.md b/README.md index 36ed6fe..b1d40bc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +## Deploying + +First create the necessary secrets for database and admin token access. Replace the placeholders with your actual values. +```bash +kubectl -n wynnsource-dev create secret generic wynnsource-secrets \ + --from-literal=WCS_DB_POSTGRES_DSN='postgresql+asyncpg://wynnsource:@wynnsource-pg-rw:5432/wcs_db' \ + --from-literal=WCS_DB_REDIS_DSN='redis://wynnsource-redis:6379/0' \ + --from-literal=WCS_ADMIN_TOKEN='' +``` + ## License `Copyright (C) <2026> ` diff --git a/app/config/db.py b/app/config/db.py index 76b38d2..c2db9c8 100644 --- a/app/config/db.py +++ b/app/config/db.py @@ -8,7 +8,7 @@ class DbConfig(BaseSettings): postgres_dsn: Annotated[ PostgresDsn, Field( - description="PostgreSQL connection string (leaving empty will use builtin SQLite database)", + description="PostgreSQL connection string", alias="WCS_DB_POSTGRES_DSN", ), ] = PostgresDsn("postgresql+asyncpg://user:password@localhost:5432/wcs_db") diff --git a/app/config/user.py b/app/config/user.py index 987077c..866adf4 100644 --- a/app/config/user.py +++ b/app/config/user.py @@ -3,7 +3,9 @@ class UserConfig(BaseSettings): - max_ip_records: int = Field(default=10, description="Maximum number of common IPs to store for each user") + max_ip_records: int = Field( + default=10, description="Maximum number of common IPs to store for each user" + ) USER_CONFIG = UserConfig() diff --git a/app/main.py b/app/main.py index 282835d..9bf2bfd 100644 --- a/app/main.py +++ b/app/main.py @@ -60,10 +60,18 @@ async def lifespan(app: FastAPI): @app.get("/") +@app.get("/healthz", include_in_schema=False) async def read_root() -> StatusResponse: return STATUS_RESPONSE +@app.get("/readyz", include_in_schema=False) +async def read_readyz() -> StatusResponse: + # TODO check db connections and other dependencies here + # for now just return success for dev + return STATUS_RESPONSE + + @app.get("/docs", include_in_schema=False) @app.get("/doc", include_in_schema=False) async def scalar_ui(): diff --git a/deploy/configmap.yaml b/deploy/configmap.yaml new file mode 100644 index 0000000..7f9ec0c --- /dev/null +++ b/deploy/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wynnsource-config +data: + LEVEL: "DEBUG" + MAX_IP_RECORDS: "10" diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..c42686e --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wynnsource-server +spec: + replicas: 1 + selector: + matchLabels: + app: wynnsource-server + strategy: + type: RollingUpdate + template: + metadata: + labels: + app: wynnsource-server + spec: + securityContext: + runAsNonRoot: true + containers: + - name: server + image: ghcr.io/wynnsource/wynnsource-server:latest + ports: + - containerPort: 8000 + name: http + envFrom: + - configMapRef: + name: wynnsource-config + - secretRef: + name: wynnsource-secrets + livenessProbe: + httpGet: { path: /healthz, port: 8000 } + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: { path: /readyz, port: 8000 } + initialDelaySeconds: 5 + periodSeconds: 10 + startupProbe: + httpGet: { path: /healthz, port: 8000 } + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 10 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 500m, memory: 512Mi } diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..07c3a0c --- /dev/null +++ b/deploy/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: wynnsource-dev +resources: + - postgres.yaml + - redis.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + - migration-job.yaml +images: + - name: ghcr.io/wynnsource/wynnsource-server + newTag: latest diff --git a/deploy/migration-job.yaml b/deploy/migration-job.yaml new file mode 100644 index 0000000..a60be9c --- /dev/null +++ b/deploy/migration-job.yaml @@ -0,0 +1,19 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: wynnsource-migration + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 3 + template: + spec: + containers: + - name: migration + image: ghcr.io/wynnsource/wynnsource-server:latest + command: ["uv", "run", "alembic", "upgrade", "head"] + envFrom: + - secretRef: + name: wynnsource-secrets + restartPolicy: OnFailure diff --git a/deploy/postgres.yaml b/deploy/postgres.yaml new file mode 100644 index 0000000..169f3ca --- /dev/null +++ b/deploy/postgres.yaml @@ -0,0 +1,15 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: wynnsource-pg +spec: + instances: 1 + storage: + size: 5Gi + postgresql: + parameters: + max_connections: "100" + bootstrap: + initdb: + database: wcs_db + owner: wynnsource diff --git a/deploy/redis.yaml b/deploy/redis.yaml new file mode 100644 index 0000000..a3809d2 --- /dev/null +++ b/deploy/redis.yaml @@ -0,0 +1,17 @@ +apiVersion: redis.redis.opstreelabs.in/v1beta2 +kind: Redis +metadata: + name: wynnsource-redis +spec: + kubernetesConfig: + image: redis:7-alpine + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 200m, memory: 128Mi } + storage: + volumeClaimTemplate: + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..a24c6cb --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: wynnsource-server + labels: + app: wynnsource-server +spec: + type: ClusterIP + selector: + app: wynnsource-server + ports: + - port: 8000 + targetPort: http + protocol: TCP + name: http diff --git a/docker-compose.yaml b/docker-compose.yaml index 97745a1..5ba6f94 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,25 +1,53 @@ services: app: - build: - context: . - dockerfile: Dockerfile + build: . ports: - - "127.0.0.1:8000:8000" + - "8000:8000" environment: - - WCS_DB_POSTGRES_DSN=postgresql+asyncpg://postgres:password@db:5432/wynndb + - WCS_DB_POSTGRES_DSN=postgresql+asyncpg://user:password@db:5432/wcs_db + - WCS_DB_REDIS_DSN=redis://redis:6379/0 + - WCS_ADMIN_TOKEN=example_admin_token depends_on: - - db + migration: + condition: service_completed_successfully + redis: + condition: service_started restart: unless-stopped + networks: + - wcs_internal + + migration: + build: . + command: uv run alembic upgrade head + environment: + - WCS_DB_POSTGRES_DSN=postgresql+asyncpg://user:password@db:5432/wcs_db + depends_on: + db: + condition: service_healthy + networks: + - wcs_internal db: image: postgres:15-alpine environment: - POSTGRES_DB: wynndb - POSTGRES_USER: postgres + POSTGRES_DB: wcs_db + POSTGRES_USER: user POSTGRES_PASSWORD: password - volumes: - - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d wcs_db"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - wcs_internal + + redis: + image: redis:7-alpine restart: unless-stopped + networks: + - wcs_internal -volumes: - postgres_data: \ No newline at end of file +networks: + wcs_internal: + driver: bridge \ No newline at end of file diff --git a/schema b/schema index c3549ce..15d4474 160000 --- a/schema +++ b/schema @@ -1 +1 @@ -Subproject commit c3549ce69a9417355d96d542f4e2f9bfa18f4cf2 +Subproject commit 15d4474a48782a96d543860536d0bd97ad5c8c47 From 3e2502ee002d1086b820b44382fd428484b5a129 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 19:03:18 -0500 Subject: [PATCH 10/78] feat: add Docker Buildx setup step in CI/CD pipeline --- .github/workflows/deploy.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 71bf91a..2aa6068 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -23,6 +23,8 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: From 6b1088d49846f10b1587c378718e4b60576ea05e Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 19:05:08 -0500 Subject: [PATCH 11/78] feat: move image name setup to a dedicated step in CI/CD pipeline --- .github/workflows/deploy.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 2aa6068..b5a82f0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -6,7 +6,6 @@ on: - "v*.*.*" env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/wynnsource-server jobs: build-and-update: runs-on: ubuntu-latest @@ -14,6 +13,8 @@ jobs: contents: write packages: write steps: + - name: Set image name + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/wynnsource-server" >> $GITHUB_ENV - uses: actions/checkout@v4 with: submodules: recursive From 469d90f0989d72259750348643b7a5209843e898 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 19:06:36 -0500 Subject: [PATCH 12/78] feat: update .gitignore to include README.md and add new README.md file --- .gitignore | 1 + generated/README.md | 0 2 files changed, 1 insertion(+) create mode 100644 generated/README.md diff --git a/.gitignore b/.gitignore index a2fae46..a5d2dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -204,5 +204,6 @@ pyrightconfig.json generated/* !generated/wynnsource !generated/pyproject.toml +!generated/README.md generated/wynnsource/* !generated/wynnsource/__init__.py \ No newline at end of file diff --git a/generated/README.md b/generated/README.md new file mode 100644 index 0000000..e69de29 From 629e584a78493011acd400ccdc3c539465acc561 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 00:07:41 +0000 Subject: [PATCH 13/78] chore: update image to 469d90f0989d72259750348643b7a5209843e898 --- deploy/kustomization.yaml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 07c3a0c..be8ec8e 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -2,12 +2,13 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: wynnsource-dev resources: - - postgres.yaml - - redis.yaml - - configmap.yaml - - deployment.yaml - - service.yaml - - migration-job.yaml +- postgres.yaml +- redis.yaml +- configmap.yaml +- deployment.yaml +- service.yaml +- migration-job.yaml images: - - name: ghcr.io/wynnsource/wynnsource-server - newTag: latest +- name: ghcr.io/wynnsource/wynnsource-server + newName: ghcr.io/wynnsource/wynnsource-server + newTag: 469d90f0989d72259750348643b7a5209843e898 From e55b30089d91512878c0a21671eb4d9f012712df Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 19:17:41 -0500 Subject: [PATCH 14/78] fix: update submodule URL to use HTTPS instead of SSH --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 25e60c6..2437edb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "schema"] path = schema - url = git@github.com:WynnSource/schema.git + url = https://github.com/WynnSource/schema.git branch = main From ac6afd4e7ddf7afacd6bf468ae1cfdf03cba7522 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 19:33:55 -0500 Subject: [PATCH 15/78] fix: use sync wave to control dep --- deploy/deployment.yaml | 2 ++ deploy/migration-job.yaml | 3 ++- deploy/postgres.yaml | 2 ++ deploy/redis.yaml | 2 ++ deploy/service.yaml | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index c42686e..9ee9408 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -2,6 +2,8 @@ apiVersion: apps/v1 kind: Deployment metadata: name: wynnsource-server + annotations: + argocd.argoproj.io/sync-wave: "2" spec: replicas: 1 selector: diff --git a/deploy/migration-job.yaml b/deploy/migration-job.yaml index a60be9c..09f5b2c 100644 --- a/deploy/migration-job.yaml +++ b/deploy/migration-job.yaml @@ -3,8 +3,9 @@ kind: Job metadata: name: wynnsource-migration annotations: - argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook: Sync argocd.argoproj.io/hook-delete-policy: BeforeHookCreation + argocd.argoproj.io/sync-wave: "1" spec: backoffLimit: 3 template: diff --git a/deploy/postgres.yaml b/deploy/postgres.yaml index 169f3ca..455bf80 100644 --- a/deploy/postgres.yaml +++ b/deploy/postgres.yaml @@ -2,6 +2,8 @@ apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: wynnsource-pg + annotations: + argocd.argoproj.io/sync-wave: "0" spec: instances: 1 storage: diff --git a/deploy/redis.yaml b/deploy/redis.yaml index a3809d2..cee2880 100644 --- a/deploy/redis.yaml +++ b/deploy/redis.yaml @@ -2,6 +2,8 @@ apiVersion: redis.redis.opstreelabs.in/v1beta2 kind: Redis metadata: name: wynnsource-redis + annotations: + argocd.argoproj.io/sync-wave: "0" spec: kubernetesConfig: image: redis:7-alpine diff --git a/deploy/service.yaml b/deploy/service.yaml index a24c6cb..918f800 100644 --- a/deploy/service.yaml +++ b/deploy/service.yaml @@ -2,6 +2,8 @@ apiVersion: v1 kind: Service metadata: name: wynnsource-server + annotations: + argocd.argoproj.io/sync-wave: "2" labels: app: wynnsource-server spec: From 76ff09226de596174e79eb58ab9bbd2a82f7a6c6 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:10:08 -0500 Subject: [PATCH 16/78] feat: refactor database configuration and add computed fields for DSNs --- app/config/db.py | 47 ++++++++++++++++++++++++++++-------------- deploy/deployment.yaml | 28 ++++++++++++++++++++++++- docker-compose.yaml | 15 ++++++++++---- test_pydantic.py | 44 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 test_pydantic.py diff --git a/app/config/db.py b/app/config/db.py index c2db9c8..137654b 100644 --- a/app/config/db.py +++ b/app/config/db.py @@ -1,25 +1,40 @@ from typing import Annotated -from pydantic import Field, PostgresDsn, RedisDsn +from pydantic import Field, PostgresDsn, RedisDsn, computed_field from pydantic_settings import BaseSettings class DbConfig(BaseSettings): - postgres_dsn: Annotated[ - PostgresDsn, - Field( - description="PostgreSQL connection string", - alias="WCS_DB_POSTGRES_DSN", - ), - ] = PostgresDsn("postgresql+asyncpg://user:password@localhost:5432/wcs_db") - - redis_dsn: Annotated[ - RedisDsn | None, - Field( - description="Redis connection string (leaving empty will disable Redis caching)", - alias="WCS_DB_REDIS_DSN", - ), - ] = RedisDsn("redis://localhost:6379/0") + postgres_host: Annotated[str, Field(alias="POSTGRES_HOST")] = "localhost" + postgres_port: Annotated[int, Field(alias="POSTGRES_PORT")] = 5432 + postgres_user: Annotated[str, Field(alias="POSTGRES_USER")] = "postgres" + postgres_password: Annotated[str, Field(alias="POSTGRES_PASSWORD")] = "postgres" + postgres_db: Annotated[str, Field(alias="POSTGRES_DB")] = "wcs_db" + + redis_host: Annotated[str, Field(alias="REDIS_HOST")] = "localhost" + redis_port: Annotated[int, Field(alias="REDIS_PORT")] = 6379 + + @computed_field + @property + def postgres_dsn(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=self.postgres_user, + password=self.postgres_password, + host=self.postgres_host, + port=self.postgres_port, + path=self.postgres_db, + ) + + @computed_field + @property + def redis_dsn(self) -> RedisDsn: + return RedisDsn.build( + scheme="redis", + host=self.redis_host, + port=self.redis_port, + path="0", + ) DB_CONFIG = DbConfig() diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 9ee9408..ccf2ae8 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -29,6 +29,32 @@ spec: name: wynnsource-config - secretRef: name: wynnsource-secrets + env: + - name: POSTGRES_HOST + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: host + - name: POSTGRES_PORT + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: port + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: user + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: password + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: dbname livenessProbe: httpGet: { path: /healthz, port: 8000 } initialDelaySeconds: 10 @@ -44,4 +70,4 @@ spec: failureThreshold: 10 resources: requests: { cpu: 100m, memory: 256Mi } - limits: { cpu: 500m, memory: 512Mi } + limits: { cpu: 1000m, memory: 1024Mi } diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ba6f94..45f2508 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,8 +4,11 @@ services: ports: - "8000:8000" environment: - - WCS_DB_POSTGRES_DSN=postgresql+asyncpg://user:password@db:5432/wcs_db - - WCS_DB_REDIS_DSN=redis://redis:6379/0 + - POSTGRES_HOST=db + - REDIS_HOST=redis + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=wcs_db - WCS_ADMIN_TOKEN=example_admin_token depends_on: migration: @@ -18,9 +21,13 @@ services: migration: build: . - command: uv run alembic upgrade head + command: uv run --no-dev alembic upgrade head environment: - - WCS_DB_POSTGRES_DSN=postgresql+asyncpg://user:password@db:5432/wcs_db + - POSTGRES_HOST=db + - REDIS_HOST=redis + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=wcs_db depends_on: db: condition: service_healthy diff --git a/test_pydantic.py b/test_pydantic.py new file mode 100644 index 0000000..062727d --- /dev/null +++ b/test_pydantic.py @@ -0,0 +1,44 @@ +from pydantic_settings import BaseSettings +from pydantic import computed_field, PostgresDsn, RedisDsn + +class DbConfig(BaseSettings): + postgres_host: str = "localhost" + postgres_port: int = 5432 + postgres_user: str = "user" + postgres_password: str = "password" + postgres_db: str = "wcs_db" + + redis_host: str = "localhost" + redis_port: int = 6379 + redis_password: str | None = None + redis_db: str = "0" + redis_enabled: bool = True + + @computed_field + @property + def postgres_dsn(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=self.postgres_user, + password=self.postgres_password, + host=self.postgres_host, + port=self.postgres_port, + path=self.postgres_db, + ) + + @computed_field + @property + def redis_dsn(self) -> RedisDsn | None: + if not self.redis_enabled: + return None + return RedisDsn.build( + scheme="redis", + password=self.redis_password, + host=self.redis_host, + port=self.redis_port, + path=self.redis_db, + ) + +cfg = DbConfig() +print(cfg.postgres_dsn) +print(cfg.redis_dsn) From 1e98f973154f70ae9672fedeedd1fda6882a4fab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 01:11:18 +0000 Subject: [PATCH 17/78] chore: update image to 76ff09226de596174e79eb58ab9bbd2a82f7a6c6 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index be8ec8e..c68418d 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 469d90f0989d72259750348643b7a5209843e898 + newTag: 76ff09226de596174e79eb58ab9bbd2a82f7a6c6 From 446c97189d34e0d9541c16fea94b719c22e68ad6 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:36:36 -0500 Subject: [PATCH 18/78] fix: add Redis configuration and update PostgreSQL environment variables in deployment and migration job --- deploy/deployment.yaml | 4 ++++ deploy/migration-job.yaml | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index ccf2ae8..71d028d 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -55,6 +55,10 @@ spec: secretKeyRef: name: wynnsource-pg-app key: dbname + - name: REDIS_HOST + value: wynnsource-redis + - name: REDIS_PORT + value: "6379" livenessProbe: httpGet: { path: /healthz, port: 8000 } initialDelaySeconds: 10 diff --git a/deploy/migration-job.yaml b/deploy/migration-job.yaml index 09f5b2c..e5f2b2a 100644 --- a/deploy/migration-job.yaml +++ b/deploy/migration-job.yaml @@ -17,4 +17,30 @@ spec: envFrom: - secretRef: name: wynnsource-secrets + env: + - name: POSTGRES_HOST + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: host + - name: POSTGRES_PORT + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: port + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: user + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: password + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: wynnsource-pg-app + key: dbname restartPolicy: OnFailure From 3732634202758efdd4d602a363718323d4280ccd Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:41:28 -0500 Subject: [PATCH 19/78] feat: add step to delete old container images in deployment workflow --- .github/workflows/deploy.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index b5a82f0..c4d5ac7 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -47,3 +47,9 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add deploy/kustomization.yaml git diff --cached --quiet || git commit -m "chore: update image to ${{ github.sha }}" && git push + - name: Delete old images + uses: actions/delete-package-versions@v5 + with: + package-type: container + min-versions-to-keep: 1 + delete-only-untagged-versions: true From e095dafce5ea52872134f5daa69c7d995c71dfd3 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:41:54 -0500 Subject: [PATCH 20/78] fix: specify package name for old image deletion in deployment workflow --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c4d5ac7..4cfd71b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -51,5 +51,6 @@ jobs: uses: actions/delete-package-versions@v5 with: package-type: container + package-name: wynnsource-server min-versions-to-keep: 1 delete-only-untagged-versions: true From 52b123d30a91696ea754f353eab4a3ab582c4043 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 01:42:56 +0000 Subject: [PATCH 21/78] chore: update image to e095dafce5ea52872134f5daa69c7d995c71dfd3 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index c68418d..01cb3f0 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 76ff09226de596174e79eb58ab9bbd2a82f7a6c6 + newTag: e095dafce5ea52872134f5daa69c7d995c71dfd3 From 6ec1e01d9f89554f90fac48c218e433f99a84c65 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:43:23 -0500 Subject: [PATCH 22/78] fix: add --no-dev flag to migration command for production compatibility --- deploy/migration-job.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/migration-job.yaml b/deploy/migration-job.yaml index e5f2b2a..331898d 100644 --- a/deploy/migration-job.yaml +++ b/deploy/migration-job.yaml @@ -13,7 +13,7 @@ spec: containers: - name: migration image: ghcr.io/wynnsource/wynnsource-server:latest - command: ["uv", "run", "alembic", "upgrade", "head"] + command: ["uv", "run", "--no-dev", "alembic", "upgrade", "head"] envFrom: - secretRef: name: wynnsource-secrets From 10d280d8ddfa5baeccff65de73a36e004676665d Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:44:54 -0500 Subject: [PATCH 23/78] fix: add user and group for application execution in Dockerfile --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8d311c3..964426e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" +RUN addgroup --system wynnsource && adduser --system --ingroup wynnsource wynnsource +RUN chown -R wynnsource:wynnsource /app +USER wynnsource + # Run the FastAPI application EXPOSE 8000 CMD fastapi run \ No newline at end of file From 4ab38f87fb7994f67100cf3d3719ee3e006ef7f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 01:46:18 +0000 Subject: [PATCH 24/78] chore: update image to 10d280d8ddfa5baeccff65de73a36e004676665d --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 01cb3f0..8f77a4b 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: e095dafce5ea52872134f5daa69c7d995c71dfd3 + newTag: 10d280d8ddfa5baeccff65de73a36e004676665d From 0e9fa0a6d5bb89191071c8fbd73d944c0a1bb126 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:51:53 -0500 Subject: [PATCH 25/78] fix: increase minimum versions to keep for old images in deployment workflow --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4cfd71b..9b480af 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -52,5 +52,5 @@ jobs: with: package-type: container package-name: wynnsource-server - min-versions-to-keep: 1 + min-versions-to-keep: 3 delete-only-untagged-versions: true From 4bc89c7bdbb3cf49a8dd373bac2748d951bafec9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 01:53:09 +0000 Subject: [PATCH 26/78] chore: update image to 0e9fa0a6d5bb89191071c8fbd73d944c0a1bb126 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 8f77a4b..c2f9a15 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 10d280d8ddfa5baeccff65de73a36e004676665d + newTag: 0e9fa0a6d5bb89191071c8fbd73d944c0a1bb126 From fd5e457f9e9b298907fac79d58a001ca35dddc78 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 20:56:27 -0500 Subject: [PATCH 27/78] fix: set UV_NO_CACHE environment variable for improved caching behavior --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 964426e..b67601f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,9 @@ ENV PATH="/app/.venv/bin:$PATH" RUN addgroup --system wynnsource && adduser --system --ingroup wynnsource wynnsource RUN chown -R wynnsource:wynnsource /app + +ENV UV_NO_CACHE=1 + USER wynnsource # Run the FastAPI application From aae80826508edd507ac6729f0eaca2f5a80c5925 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 01:57:22 +0000 Subject: [PATCH 28/78] chore: update image to fd5e457f9e9b298907fac79d58a001ca35dddc78 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index c2f9a15..6b3e936 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 0e9fa0a6d5bb89191071c8fbd73d944c0a1bb126 + newTag: fd5e457f9e9b298907fac79d58a001ca35dddc78 From 9492100e84457382ac84ee7f1b8f8b2e1e4f46ee Mon Sep 17 00:00:00 2001 From: FYWinds Date: Fri, 27 Feb 2026 21:00:51 -0500 Subject: [PATCH 29/78] fix: update user and group IDs for consistency in Dockerfile --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b67601f..3568135 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,12 +31,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" -RUN addgroup --system wynnsource && adduser --system --ingroup wynnsource wynnsource -RUN chown -R wynnsource:wynnsource /app +RUN addgroup --system --gid 1000 wynnsource && adduser --system --uid 1000 --ingroup wynnsource wynnsource +RUN chown -R 1000:1000 /app ENV UV_NO_CACHE=1 -USER wynnsource +USER 1000 # Run the FastAPI application EXPOSE 8000 From 01d304093873017a105a8b2d6d9a5f40ee64b363 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 02:01:54 +0000 Subject: [PATCH 30/78] chore: update image to 9492100e84457382ac84ee7f1b8f8b2e1e4f46ee --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 6b3e936..fe46551 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: fd5e457f9e9b298907fac79d58a001ca35dddc78 + newTag: 9492100e84457382ac84ee7f1b8f8b2e1e4f46ee From 4abfd72ed3612430919c3acc8a9370a32ba807cc Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sat, 28 Feb 2026 21:20:40 -0500 Subject: [PATCH 31/78] feat: add delete and clear functionality for beta items --- README.md | 29 +++++++++++++++++++++++++++-- app/module/beta/model.py | 7 +++++++ app/module/beta/router.py | 27 ++++++++++++++++++++++++--- app/module/beta/service.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b1d40bc..2109cf3 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,35 @@ First create the necessary secrets for database and admin token access. Replace the placeholders with your actual values. ```bash kubectl -n wynnsource-dev create secret generic wynnsource-secrets \ - --from-literal=WCS_DB_POSTGRES_DSN='postgresql+asyncpg://wynnsource:@wynnsource-pg-rw:5432/wcs_db' \ - --from-literal=WCS_DB_REDIS_DSN='redis://wynnsource-redis:6379/0' \ --from-literal=WCS_ADMIN_TOKEN='' ``` +Or you can use sealed secrets for better security. + +Then use this as an example to deploy the application using ArgoCD. +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: wynnsource-dev + namespace: argocd +spec: + project: default + source: + repoURL: git@github.com:WynnSource/WynnSourceServer.git + targetRevision: dev + path: deploy + destination: + server: https://kubernetes.default.svc + namespace: wynnsource-dev + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` +You have to add your ingress configuration as well, +also make sure there is the `X-Real-IP` header from the ingress controller for proper client IP logging and rate limiting. ## License diff --git a/app/module/beta/model.py b/app/module/beta/model.py index abb8031..15779fa 100644 --- a/app/module/beta/model.py +++ b/app/module/beta/model.py @@ -33,3 +33,10 @@ async def add_item(self, item: WynnSourceItem) -> BetaItem: async def list_items(self) -> Sequence[BetaItem]: result = await self.session.execute(select(BetaItem)) return result.scalars().all() + + async def delete_item(self, name: str) -> None: + result = await self.session.execute(select(BetaItem).where(BetaItem.name == name)) + beta_item = result.scalar_one_or_none() + if beta_item: + await self.session.delete(beta_item) + await self.session.flush() diff --git a/app/module/beta/router.py b/app/module/beta/router.py index 04aed6e..83d3f1c 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -3,12 +3,13 @@ from app.core import metadata from app.core.db import SessionDep from app.core.router import DocedAPIRoute -from app.module.beta.schema import BetaItemListResponse, NewItemSubmission -from app.module.beta.service import get_beta_items, handle_item_submission from app.schemas.enums import ItemReturnType from app.schemas.enums.tag import ApiTag from app.schemas.response import EmptyResponse, WCSResponse +from .schema import BetaItemListResponse, NewItemSubmission +from .service import delete_beta_item, get_beta_items, handle_item_submission + BetaRouter = APIRouter(route_class=DocedAPIRoute, prefix="/beta", tags=[ApiTag.BETA]) @@ -30,10 +31,30 @@ async def list_beta_items( @BetaRouter.post("/items", summary="Submit new Beta Item") -@metadata.rate_limit(limit=30, period=60) +@metadata.rate_limit(limit=300, period=60) async def submit_beta_item(items: NewItemSubmission, session: SessionDep) -> EmptyResponse: """ Submit new items to be added to the beta list. """ await handle_item_submission(items, session) return EmptyResponse() + + +@BetaRouter.delete("/items", summary="Delete Beta Items") +@metadata.permission("beta.items.write") +async def delete_beta_items(items: list[str], session: SessionDep) -> EmptyResponse: + """ + Delete items from the beta list by name. + """ + await delete_beta_item(items, session) + return EmptyResponse() + + +@BetaRouter.delete("/items/clear", summary="Clear Beta Items") +@metadata.permission("beta.items.write") +async def clear_beta_items(session: SessionDep) -> EmptyResponse: + """ + Clear all items from the beta list. + """ + await clear_beta_items(session) + return EmptyResponse() diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 601d5b3..9e66824 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -28,3 +28,32 @@ async def get_beta_items(session: AsyncSession) -> list[bytes]: itemRepo = BetaItemRepository(session) beta_items = await itemRepo.list_items() return [item.item for item in beta_items] + + +async def delete_beta_item(items: list[str], session: AsyncSession): + itemRepo = BetaItemRepository(session) + deleted_count = 0 + for item in items: + try: + await itemRepo.delete_item(item) + deleted_count += 1 + except Exception as e: + LOGGER.debug(f"Failed to delete item: {item}, error: {e}") + # Silently ignore failed deletions + pass + LOGGER.info(f"Deleted {deleted_count}/{len(items)} items from beta") + + +async def clear_beta_items(session: AsyncSession): + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.list_items() + deleted_count = 0 + for item in beta_items: + try: + await itemRepo.delete_item(item.name) + deleted_count += 1 + except Exception as e: + LOGGER.debug(f"Failed to delete item: {item.name}, error: {e}") + # Silently ignore failed deletions + pass + LOGGER.info(f"Cleared {deleted_count} items from beta") From a106f22dda8fe23e012ff0b812fb57f0a7c19743 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 02:21:59 +0000 Subject: [PATCH 32/78] chore: update image to 4abfd72ed3612430919c3acc8a9370a32ba807cc --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index fe46551..5ae424c 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 9492100e84457382ac84ee7f1b8f8b2e1e4f46ee + newTag: 4abfd72ed3612430919c3acc8a9370a32ba807cc From 104f7494f78660b26b54925300f142ba871440b0 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sat, 28 Feb 2026 21:26:36 -0500 Subject: [PATCH 33/78] fix: service handler name causing recursion error --- app/module/beta/router.py | 11 ++++++++--- app/module/beta/service.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/module/beta/router.py b/app/module/beta/router.py index 83d3f1c..eb7da0f 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -8,7 +8,12 @@ from app.schemas.response import EmptyResponse, WCSResponse from .schema import BetaItemListResponse, NewItemSubmission -from .service import delete_beta_item, get_beta_items, handle_item_submission +from .service import ( + get_beta_items, + handle_clear_beta_items, + handle_delete_beta_items, + handle_item_submission, +) BetaRouter = APIRouter(route_class=DocedAPIRoute, prefix="/beta", tags=[ApiTag.BETA]) @@ -46,7 +51,7 @@ async def delete_beta_items(items: list[str], session: SessionDep) -> EmptyRespo """ Delete items from the beta list by name. """ - await delete_beta_item(items, session) + await handle_delete_beta_items(items, session) return EmptyResponse() @@ -56,5 +61,5 @@ async def clear_beta_items(session: SessionDep) -> EmptyResponse: """ Clear all items from the beta list. """ - await clear_beta_items(session) + await handle_clear_beta_items(session) return EmptyResponse() diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 9e66824..38925a7 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -30,7 +30,7 @@ async def get_beta_items(session: AsyncSession) -> list[bytes]: return [item.item for item in beta_items] -async def delete_beta_item(items: list[str], session: AsyncSession): +async def handle_delete_beta_items(items: list[str], session: AsyncSession): itemRepo = BetaItemRepository(session) deleted_count = 0 for item in items: @@ -44,7 +44,7 @@ async def delete_beta_item(items: list[str], session: AsyncSession): LOGGER.info(f"Deleted {deleted_count}/{len(items)} items from beta") -async def clear_beta_items(session: AsyncSession): +async def handle_clear_beta_items(session: AsyncSession): itemRepo = BetaItemRepository(session) beta_items = await itemRepo.list_items() deleted_count = 0 From cbd4e1a2adf85c615bd60c0dd2fa11bfb2f4a1c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 02:27:47 +0000 Subject: [PATCH 34/78] chore: update image to 104f7494f78660b26b54925300f142ba871440b0 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 5ae424c..4fc91c9 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 4abfd72ed3612430919c3acc8a9370a32ba807cc + newTag: 104f7494f78660b26b54925300f142ba871440b0 From 5fd2d1dbb02b26c27798375fbceee66e9e312519 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 13:49:23 -0500 Subject: [PATCH 35/78] feat: enhance item submission handling and validation for beta items --- app/module/beta/model.py | 16 +++++++---- app/module/beta/service.py | 57 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/module/beta/model.py b/app/module/beta/model.py index 15779fa..e859553 100644 --- a/app/module/beta/model.py +++ b/app/module/beta/model.py @@ -18,17 +18,21 @@ class BetaItem(Base): class BetaItemRepository(BaseRepository): - async def add_item(self, item: WynnSourceItem) -> BetaItem: + async def add_item(self, item: WynnSourceItem): existing = ( await self.session.execute(select(BetaItem).where(BetaItem.name == item.name)) ).scalar_one_or_none() if existing: - raise ValueError("Item already exists in beta list") - beta_item = BetaItem(name=item.name, item=item.SerializeToString()) - self.session.add(beta_item) + existing.item = item.SerializeToString() + self.session.add(existing) + else: + beta_item = BetaItem(name=item.name, item=item.SerializeToString()) + self.session.add(beta_item) await self.session.flush() - await self.session.refresh(beta_item) - return beta_item + + async def get_item(self, name: str) -> BetaItem | None: + result = await self.session.execute(select(BetaItem).where(BetaItem.name == name)) + return result.scalar_one_or_none() async def list_items(self) -> Sequence[BetaItem]: result = await self.session.execute(select(BetaItem)) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 38925a7..272a6b3 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -4,6 +4,7 @@ from app.core.log import LOGGER from wynnsource import WynnSourceItem +from wynnsource.item.gear_pb2 import GearType from .model import BetaItemRepository from .schema import NewItemSubmission @@ -14,16 +15,66 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe succeeds = 0 for item in submission.items: try: - await itemRepo.add_item(WynnSourceItem.FromString(b64decode(item))) + item = WynnSourceItem.FromString(b64decode(item)) + existing = await itemRepo.get_item(item.name) + existing = WynnSourceItem.FromString(existing.item) if existing else None + if not check_item_validity(item): + LOGGER.debug(f"Item from submission is invalid: {item.name}") + continue + if existing and item == existing: + LOGGER.debug(f"Item from submission is identical to existing item: {item.name}") + continue + if existing and item != existing: + LOGGER.debug( + f"Item from submission is different from existing item: {item.name}," + + " overwriting" + ) + await itemRepo.add_item(item) succeeds += 1 except Exception as e: - LOGGER.debug(f"Failed to add item from submission: {item}, error: {e}") - # Silently ignore duplicate item submissions or invalid items + LOGGER.debug(f"Failed to add item from submission, error: {e}") + # Silently ignore failed items pass LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta submission") +def check_item_validity(item: WynnSourceItem) -> bool: + if item.name == "": + return False + if item.level == 0: + return False + if item.rarity == 0: + return False + if item.HasField("gear"): + return False + if item.gear.type == 0: + return False + if not item.gear.HasField("requirements"): + return False + if not item.gear.HasField("unidentified"): + return False + if (not item.gear.unidentified.HasField("identifications")) or len( + item.gear.unidentified.identifications + ) == 0: + return False + if ( + item.gear.type + in [ + GearType.GEAR_TYPE_BOW, + GearType.GEAR_TYPE_WAND, + GearType.GEAR_TYPE_DAGGER, + GearType.GEAR_TYPE_SPEAR, + GearType.GEAR_TYPE_RELIK, + ] + ) and item.gear.weapon_stats is None: + return False + if item.gear.armor_stats is None: + return False + + return True + + async def get_beta_items(session: AsyncSession) -> list[bytes]: itemRepo = BetaItemRepository(session) beta_items = await itemRepo.list_items() From 574ba852f9136cdb77cdb92ce2e33cb684baadd8 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 13:51:00 -0500 Subject: [PATCH 36/78] fix: reduce cache expiration time for beta items list --- app/module/beta/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/module/beta/router.py b/app/module/beta/router.py index eb7da0f..da0e77f 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -20,7 +20,7 @@ @BetaRouter.get("/items", summary="List Beta Items") @metadata.rate_limit(limit=10, period=60) -@metadata.cached(expire=300) +@metadata.cached(expire=30) async def list_beta_items( session: SessionDep, item_return_type: ItemReturnType = ItemReturnType.B64, From 04315f1c33e44ce0eb6d41d285fabf5ce115ba1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 18:52:42 +0000 Subject: [PATCH 37/78] chore: update image to 574ba852f9136cdb77cdb92ce2e33cb684baadd8 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 4fc91c9..c8a8643 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 104f7494f78660b26b54925300f142ba871440b0 + newTag: 574ba852f9136cdb77cdb92ce2e33cb684baadd8 From d83720affedcd7ca8fca47d50140579138bb3bcc Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 13:59:52 -0500 Subject: [PATCH 38/78] fix: correct item validity checks for gear and stats presence --- app/module/beta/service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 272a6b3..12ac772 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -46,7 +46,7 @@ def check_item_validity(item: WynnSourceItem) -> bool: return False if item.rarity == 0: return False - if item.HasField("gear"): + if not item.HasField("gear"): return False if item.gear.type == 0: return False @@ -67,9 +67,9 @@ def check_item_validity(item: WynnSourceItem) -> bool: GearType.GEAR_TYPE_SPEAR, GearType.GEAR_TYPE_RELIK, ] - ) and item.gear.weapon_stats is None: + ) and not item.gear.HasField("weapon_stats"): return False - if item.gear.armor_stats is None: + if not item.gear.HasField("armor_stats"): return False return True From 05b3201691102582f1d9eb18b3ff880002c615d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 19:00:58 +0000 Subject: [PATCH 39/78] chore: update image to d83720affedcd7ca8fca47d50140579138bb3bcc --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index c8a8643..810d3e1 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 574ba852f9136cdb77cdb92ce2e33cb684baadd8 + newTag: d83720affedcd7ca8fca47d50140579138bb3bcc From 8596d481c9b1c827d52f7ab874065b34712e6a7e Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 14:05:04 -0500 Subject: [PATCH 40/78] fix: simplify item validity check for unidentified gear identifications --- app/module/beta/service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 12ac772..3707f30 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -54,9 +54,7 @@ def check_item_validity(item: WynnSourceItem) -> bool: return False if not item.gear.HasField("unidentified"): return False - if (not item.gear.unidentified.HasField("identifications")) or len( - item.gear.unidentified.identifications - ) == 0: + if len(item.gear.unidentified.identifications) == 0: return False if ( item.gear.type From ecf1b386d587349ed14056c9dc583595eae04d5f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 19:06:15 +0000 Subject: [PATCH 41/78] chore: update image to 8596d481c9b1c827d52f7ab874065b34712e6a7e --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 810d3e1..c5b9e98 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: d83720affedcd7ca8fca47d50140579138bb3bcc + newTag: 8596d481c9b1c827d52f7ab874065b34712e6a7e From 1ac2c36b70675c8c0271c0a2c97728e0cb61c9f5 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 14:31:42 -0500 Subject: [PATCH 42/78] fix: add debug logging for disallowed submission versions --- app/module/beta/service.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 3707f30..6de36cd 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -9,8 +9,15 @@ from .model import BetaItemRepository from .schema import NewItemSubmission +allowed_version = ["v0.2.2"] + async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: + if not any(version in submission.mod_version for version in allowed_version): + LOGGER.debug( + f"Submission version {submission.mod_version} is not allowed, skipping submission" + ) + return itemRepo = BetaItemRepository(session) succeeds = 0 for item in submission.items: From 343f8de51d24f0d734abee2f51b3a51f465c5d1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 19:32:37 +0000 Subject: [PATCH 43/78] chore: update image to 1ac2c36b70675c8c0271c0a2c97728e0cb61c9f5 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index c5b9e98..8b95e90 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 8596d481c9b1c827d52f7ab874065b34712e6a7e + newTag: 1ac2c36b70675c8c0271c0a2c97728e0cb61c9f5 From b6d94e674e11cdc3dcf6453ffbdcebc4f738f619 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 14:56:48 -0500 Subject: [PATCH 44/78] fix: correct allowed version format in item submission --- app/module/beta/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 6de36cd..85e5654 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -9,7 +9,7 @@ from .model import BetaItemRepository from .schema import NewItemSubmission -allowed_version = ["v0.2.2"] +allowed_version = ["0.2.2"] async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: From 5bd765db6a1f452652f8be3f67cec660581b5044 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 19:57:51 +0000 Subject: [PATCH 45/78] chore: update image to b6d94e674e11cdc3dcf6453ffbdcebc4f738f619 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 8b95e90..e9b6700 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 1ac2c36b70675c8c0271c0a2c97728e0cb61c9f5 + newTag: b6d94e674e11cdc3dcf6453ffbdcebc4f738f619 From 1747e7c62035ed2dd64176f7f63faa2029bc3aab Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 16:10:10 -0500 Subject: [PATCH 46/78] refactor: remove unused gear type checks in item validity --- app/module/beta/service.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 85e5654..9f3e3ed 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -4,7 +4,6 @@ from app.core.log import LOGGER from wynnsource import WynnSourceItem -from wynnsource.item.gear_pb2 import GearType from .model import BetaItemRepository from .schema import NewItemSubmission @@ -63,19 +62,6 @@ def check_item_validity(item: WynnSourceItem) -> bool: return False if len(item.gear.unidentified.identifications) == 0: return False - if ( - item.gear.type - in [ - GearType.GEAR_TYPE_BOW, - GearType.GEAR_TYPE_WAND, - GearType.GEAR_TYPE_DAGGER, - GearType.GEAR_TYPE_SPEAR, - GearType.GEAR_TYPE_RELIK, - ] - ) and not item.gear.HasField("weapon_stats"): - return False - if not item.gear.HasField("armor_stats"): - return False return True From 240b379229828401573ce1742b3128c9b3ffd3a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 21:11:08 +0000 Subject: [PATCH 47/78] chore: update image to 1747e7c62035ed2dd64176f7f63faa2029bc3aab --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index e9b6700..cbb5238 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: b6d94e674e11cdc3dcf6453ffbdcebc4f738f619 + newTag: 1747e7c62035ed2dd64176f7f63faa2029bc3aab From e3d81b70bc888f585ed00c07c931afb6b99cc22c Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 17:20:34 -0500 Subject: [PATCH 48/78] fix: update allowed version list for item submissions --- app/module/beta/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 9f3e3ed..4e64804 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -8,7 +8,7 @@ from .model import BetaItemRepository from .schema import NewItemSubmission -allowed_version = ["0.2.2"] +allowed_version = ["0.2.2", "0.2.3", "0.2.4"] async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: From 12f7df347ba5664913b3a5036a45bcb4d7471901 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 22:22:09 +0000 Subject: [PATCH 49/78] chore: update image to e3d81b70bc888f585ed00c07c931afb6b99cc22c --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index cbb5238..af6260b 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 1747e7c62035ed2dd64176f7f63faa2029bc3aab + newTag: e3d81b70bc888f585ed00c07c931afb6b99cc22c From 79b08791bb0b4ec1e2246b6a6fefe79b82d507dc Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 1 Mar 2026 18:03:40 -0500 Subject: [PATCH 50/78] fix: add forwarded-allow-ips option to FastAPI run command --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3568135..8c786d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ USER 1000 # Run the FastAPI application EXPOSE 8000 -CMD fastapi run \ No newline at end of file +CMD fastapi run --forwarded-allow-ips="127.0.0.1,10.0.0.0/8,[::1]" \ No newline at end of file From 324b8743848db314315b5e55a09ce79c8a537aa0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Mar 2026 23:04:44 +0000 Subject: [PATCH 51/78] chore: update image to 79b08791bb0b4ec1e2246b6a6fefe79b82d507dc --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index af6260b..26873aa 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: e3d81b70bc888f585ed00c07c931afb6b99cc22c + newTag: 79b08791bb0b4ec1e2246b6a6fefe79b82d507dc From 1803e0b2f2759638cef43f6aa4801029839cd31c Mon Sep 17 00:00:00 2001 From: FYWinds Date: Mon, 2 Mar 2026 09:55:01 -0500 Subject: [PATCH 52/78] feat: integrate dynamic allowed versions configuration for item submissions --- app/module/beta/config.py | 17 +++++++++++++++++ app/module/beta/service.py | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 app/module/beta/config.py diff --git a/app/module/beta/config.py b/app/module/beta/config.py new file mode 100644 index 0000000..677a300 --- /dev/null +++ b/app/module/beta/config.py @@ -0,0 +1,17 @@ +from typing import Annotated + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class BetaConfig(BaseSettings): + _beta_allowed_versions: Annotated[str, Field(alias="BETA_ALLOWED_VERSIONS")] = "" + + @property + def allowed_versions(self) -> list[str]: + return [version.strip() for version in self._beta_allowed_versions.split(",")] + + +BETA_CONFIG = BetaConfig() + +__all__ = ["BETA_CONFIG"] diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 4e64804..91763dc 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -5,10 +5,11 @@ from app.core.log import LOGGER from wynnsource import WynnSourceItem +from .config import BETA_CONFIG from .model import BetaItemRepository from .schema import NewItemSubmission -allowed_version = ["0.2.2", "0.2.3", "0.2.4"] +allowed_version = BETA_CONFIG.allowed_versions async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: From 8df0459455497307e2738a6db25059a21077af66 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 14:56:17 +0000 Subject: [PATCH 53/78] chore: update image to 1803e0b2f2759638cef43f6aa4801029839cd31c --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 26873aa..7752994 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 79b08791bb0b4ec1e2246b6a6fefe79b82d507dc + newTag: 1803e0b2f2759638cef43f6aa4801029839cd31c From a0b74999810919ba0ae6459adb7f7e97e7719a14 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Mon, 2 Mar 2026 09:58:46 -0500 Subject: [PATCH 54/78] fix: removed configmap in kustomization --- deploy/kustomization.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 7752994..8cd5137 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -2,13 +2,13 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: wynnsource-dev resources: -- postgres.yaml -- redis.yaml -- configmap.yaml -- deployment.yaml -- service.yaml -- migration-job.yaml + - postgres.yaml + - redis.yaml + # - configmap.yaml + - deployment.yaml + - service.yaml + - migration-job.yaml images: -- name: ghcr.io/wynnsource/wynnsource-server - newName: ghcr.io/wynnsource/wynnsource-server - newTag: 1803e0b2f2759638cef43f6aa4801029839cd31c + - name: ghcr.io/wynnsource/wynnsource-server + newName: ghcr.io/wynnsource/wynnsource-server + newTag: 1803e0b2f2759638cef43f6aa4801029839cd31c From 4b9b803f84ac647399fbee60fa366ad95a39e2ee Mon Sep 17 00:00:00 2001 From: FYWinds Date: Mon, 2 Mar 2026 10:08:56 -0500 Subject: [PATCH 55/78] feat: add logging for loaded beta configuration allowed versions --- app/module/beta/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/module/beta/config.py b/app/module/beta/config.py index 677a300..b8c2ea0 100644 --- a/app/module/beta/config.py +++ b/app/module/beta/config.py @@ -3,6 +3,8 @@ from pydantic import Field from pydantic_settings import BaseSettings +from app.core.log import LOGGER + class BetaConfig(BaseSettings): _beta_allowed_versions: Annotated[str, Field(alias="BETA_ALLOWED_VERSIONS")] = "" @@ -13,5 +15,6 @@ def allowed_versions(self) -> list[str]: BETA_CONFIG = BetaConfig() +LOGGER.info(f"Loaded beta config with allowed versions: {BETA_CONFIG.allowed_versions}") __all__ = ["BETA_CONFIG"] From 8fc3d82134482b9498e85c290e78d13537ed628a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 15:10:00 +0000 Subject: [PATCH 56/78] chore: update image to 4b9b803f84ac647399fbee60fa366ad95a39e2ee --- deploy/kustomization.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 8cd5137..f28781f 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -1,14 +1,14 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: wynnsource-dev -resources: - - postgres.yaml - - redis.yaml # - configmap.yaml - - deployment.yaml - - service.yaml - - migration-job.yaml +resources: +- postgres.yaml +- redis.yaml +- deployment.yaml +- service.yaml +- migration-job.yaml images: - - name: ghcr.io/wynnsource/wynnsource-server - newName: ghcr.io/wynnsource/wynnsource-server - newTag: 1803e0b2f2759638cef43f6aa4801029839cd31c +- name: ghcr.io/wynnsource/wynnsource-server + newName: ghcr.io/wynnsource/wynnsource-server + newTag: 4b9b803f84ac647399fbee60fa366ad95a39e2ee From 49e596b1446221014ef0291ae3f37d56fd1cb014 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Mon, 2 Mar 2026 10:15:58 -0500 Subject: [PATCH 57/78] fix: correct attribute name for beta allowed versions in BetaConfig --- app/module/beta/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/module/beta/config.py b/app/module/beta/config.py index b8c2ea0..0bfb61a 100644 --- a/app/module/beta/config.py +++ b/app/module/beta/config.py @@ -7,11 +7,11 @@ class BetaConfig(BaseSettings): - _beta_allowed_versions: Annotated[str, Field(alias="BETA_ALLOWED_VERSIONS")] = "" + beta_allowed_versions: Annotated[str, Field(alias="BETA_ALLOWED_VERSIONS")] = "" @property def allowed_versions(self) -> list[str]: - return [version.strip() for version in self._beta_allowed_versions.split(",")] + return [version.strip() for version in self.beta_allowed_versions.split(",")] BETA_CONFIG = BetaConfig() From 34ec2afcd267dde3176c5fefaa216c518e9ff020 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 15:18:35 +0000 Subject: [PATCH 58/78] chore: update image to 49e596b1446221014ef0291ae3f37d56fd1cb014 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index f28781f..7b4b55a 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 4b9b803f84ac647399fbee60fa366ad95a39e2ee + newTag: 49e596b1446221014ef0291ae3f37d56fd1cb014 From 01113c316f60659ff252bb62b3c771cc8bc0ea56 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Mon, 2 Mar 2026 10:53:19 -0500 Subject: [PATCH 59/78] fix: update old image deletion settings in deploy workflow --- .github/workflows/deploy.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 9b480af..65c7df3 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -24,6 +24,14 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Delete old images + uses: actions/delete-package-versions@v5 + with: + package-type: container + package-name: wynnsource-server + min-versions-to-keep: 1 + num-old-versions-to-delete: 99 + delete-only-untagged-versions: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push @@ -47,10 +55,3 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add deploy/kustomization.yaml git diff --cached --quiet || git commit -m "chore: update image to ${{ github.sha }}" && git push - - name: Delete old images - uses: actions/delete-package-versions@v5 - with: - package-type: container - package-name: wynnsource-server - min-versions-to-keep: 3 - delete-only-untagged-versions: true From cc0a2c0f5a54e8db483634c3fa30f213e896a72f Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:03:45 -0500 Subject: [PATCH 60/78] chore: increase line length limit in Ruff configuration --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 20b37d3..f8c60e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ test = ["pytest>=8.3.5", "pytest-cov>=6.1.1", "pytest-xdist>=3.6.1"] line-ending = "lf" [tool.ruff] -line-length = 100 +line-length = 120 target-version = "py312" src = ["app"] exclude = ["generated"] From 296140a1cacd38a92b6217fdb6ea0ce113c98712 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:03:53 -0500 Subject: [PATCH 61/78] fix: update PostgreSQL credentials in docker-compose --- .dev/docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.dev/docker-compose.yaml b/.dev/docker-compose.yaml index 6be8a25..2992270 100644 --- a/.dev/docker-compose.yaml +++ b/.dev/docker-compose.yaml @@ -3,8 +3,8 @@ services: image: postgres:15-alpine environment: POSTGRES_DB: wcs_db - POSTGRES_USER: user - POSTGRES_PASSWORD: password + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres ports: - "127.0.0.1:5432:5432" restart: unless-stopped From a156cf516dd6050ad0e10b41afd06a99e08ca749 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:03:56 -0500 Subject: [PATCH 62/78] feat: enhance beta item management with patching and filtering capabilities --- app/core/openapi.py | 40 +++++++++++++++++++++------ app/core/router.py | 12 ++++++--- app/module/beta/model.py | 8 +++--- app/module/beta/router.py | 35 ++++++++++++++++++++---- app/module/beta/schema.py | 18 +++++++++++-- app/module/beta/service.py | 45 +++++++++++++++++++++++++------ app/module/manage/router.py | 6 +++-- schema | 2 +- tests/proto/test_proto_codegen.py | 33 ++++------------------- 9 files changed, 139 insertions(+), 60 deletions(-) diff --git a/app/core/openapi.py b/app/core/openapi.py index 90a18ea..6cd08d9 100644 --- a/app/core/openapi.py +++ b/app/core/openapi.py @@ -31,18 +31,39 @@ def openapi(): RATE_LIMIT_DOCS = { 200: { "headers": { - "X-RateLimit-Limit": {"schema": {"type": "integer"}, "description": "Requests allowed per window"}, - "X-RateLimit-Remaining": {"schema": {"type": "integer"}, "description": "Requests remaining"}, - "X-RateLimit-Reset": {"schema": {"type": "integer"}, "description": "Seconds until reset"}, + "X-RateLimit-Limit": { + "schema": {"type": "integer"}, + "description": "Requests allowed per window", + }, + "X-RateLimit-Remaining": { + "schema": {"type": "integer"}, + "description": "Requests remaining", + }, + "X-RateLimit-Reset": { + "schema": {"type": "integer"}, + "description": "Seconds until reset", + }, }, }, 429: { "description": "Too Many Requests", "headers": { - "X-RateLimit-Limit": {"schema": {"type": "integer"}, "description": "Requests allowed per window"}, - "X-RateLimit-Remaining": {"schema": {"type": "integer"}, "description": "Requests remaining"}, - "X-RateLimit-Reset": {"schema": {"type": "integer"}, "description": "Seconds until reset"}, - "X-Retry-After": {"schema": {"type": "integer"}, "description": "Seconds until you can retry"}, + "X-RateLimit-Limit": { + "schema": {"type": "integer"}, + "description": "Requests allowed per window", + }, + "X-RateLimit-Remaining": { + "schema": {"type": "integer"}, + "description": "Requests remaining", + }, + "X-RateLimit-Reset": { + "schema": {"type": "integer"}, + "description": "Seconds until reset", + }, + "X-Retry-After": { + "schema": {"type": "integer"}, + "description": "Seconds until you can retry", + }, }, }, } @@ -50,7 +71,10 @@ def openapi(): CACHE_DOCS = { 200: { "headers": { - "X-Cache": {"schema": {"type": "string", "enum": ["HIT", "MISS"]}, "description": "Cache status"}, + "X-Cache": { + "schema": {"type": "string", "enum": ["HIT", "MISS"]}, + "description": "Cache status", + }, } } } diff --git a/app/core/router.py b/app/core/router.py index 49f5741..cce5de3 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -73,7 +73,9 @@ def __init__( responses[status_code]["description"] = doc["description"] description = self.add_description( - f"⌛ This response is cached for `{format_time(meta.cache.expire)}`.", description, endpoint + f"⌛ This response is cached for `{format_time(meta.cache.expire)}`.", + description, + endpoint, ) # Add permission info to description and inject permission dependency @@ -151,13 +153,17 @@ async def injected_endpoint(*args, **kwargs): if not has_request: new_params.append( inspect.Parameter( - f"{INJECTED_NAMESPACE}request", inspect.Parameter.KEYWORD_ONLY, annotation=Request + f"{INJECTED_NAMESPACE}request", + inspect.Parameter.KEYWORD_ONLY, + annotation=Request, ) ) if not has_response: new_params.append( inspect.Parameter( - f"{INJECTED_NAMESPACE}response", inspect.Parameter.KEYWORD_ONLY, annotation=Response + f"{INJECTED_NAMESPACE}response", + inspect.Parameter.KEYWORD_ONLY, + annotation=Response, ) ) setattr(injected_endpoint, "__signature__", sig.replace(parameters=new_params)) diff --git a/app/module/beta/model.py b/app/module/beta/model.py index e859553..abcb5fa 100644 --- a/app/module/beta/model.py +++ b/app/module/beta/model.py @@ -19,9 +19,7 @@ class BetaItem(Base): class BetaItemRepository(BaseRepository): async def add_item(self, item: WynnSourceItem): - existing = ( - await self.session.execute(select(BetaItem).where(BetaItem.name == item.name)) - ).scalar_one_or_none() + existing = (await self.session.execute(select(BetaItem).where(BetaItem.name == item.name))).scalar_one_or_none() if existing: existing.item = item.SerializeToString() self.session.add(existing) @@ -34,6 +32,10 @@ async def get_item(self, name: str) -> BetaItem | None: result = await self.session.execute(select(BetaItem).where(BetaItem.name == name)) return result.scalar_one_or_none() + async def get_items_by_names(self, names: list[str]) -> Sequence[BetaItem]: + result = await self.session.execute(select(BetaItem).where(BetaItem.name.in_(names))) + return result.scalars().all() + async def list_items(self) -> Sequence[BetaItem]: result = await self.session.execute(select(BetaItem)) return result.scalars().all() diff --git a/app/module/beta/router.py b/app/module/beta/router.py index da0e77f..c0181b8 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Body from app.core import metadata from app.core.db import SessionDep @@ -7,12 +7,14 @@ from app.schemas.enums.tag import ApiTag from app.schemas.response import EmptyResponse, WCSResponse -from .schema import BetaItemListResponse, NewItemSubmission +from .schema import BetaItemListResponse, ItemPatchSubmission, NewItemSubmission from .service import ( get_beta_items, + get_beta_items_by_name, handle_clear_beta_items, handle_delete_beta_items, handle_item_submission, + handle_patch_submission, ) BetaRouter = APIRouter(route_class=DocedAPIRoute, prefix="/beta", tags=[ApiTag.BETA]) @@ -28,10 +30,23 @@ async def list_beta_items( """ List all items in the beta list. """ + return WCSResponse(data=BetaItemListResponse(items=item_return_type.format_items(await get_beta_items(session)))) + + +@BetaRouter.post("/items/filter", summary="List Beta Items Filtered") +@metadata.rate_limit(limit=10, period=60) +@metadata.cached(expire=30) +async def list_beta_items_filtered( + session: SessionDep, + names: list[str] = Body(default_factory=list, description="Optional list of item names to filter by"), + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[BetaItemListResponse]: + """ + List items in the beta list. + If `names` query parameter is non-empty, only items with matching names will be returned. + """ return WCSResponse( - data=BetaItemListResponse( - items=item_return_type.format_items(await get_beta_items(session)) - ) + data=BetaItemListResponse(items=item_return_type.format_items(await get_beta_items_by_name(session, names))) ) @@ -45,6 +60,16 @@ async def submit_beta_item(items: NewItemSubmission, session: SessionDep) -> Emp return EmptyResponse() +@BetaRouter.post("/items/patch", summary="Patch existing Beta Items") +@metadata.rate_limit(limit=300, period=60) +async def patch_beta_items(submission: ItemPatchSubmission, session: SessionDep) -> EmptyResponse: + """ + Patch existing items in the beta list. Only fields specified in the submission will be updated. + """ + await handle_patch_submission(submission, session) + return EmptyResponse() + + @BetaRouter.delete("/items", summary="Delete Beta Items") @metadata.permission("beta.items.write") async def delete_beta_items(items: list[str], session: SessionDep) -> EmptyResponse: diff --git a/app/module/beta/schema.py b/app/module/beta/schema.py index 45c9c22..17bec63 100644 --- a/app/module/beta/schema.py +++ b/app/module/beta/schema.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import StrEnum from pydantic import BaseModel, Field @@ -14,7 +15,20 @@ class NewItemSubmission(BaseModel): class BetaItemListResponse(BaseModel): items: list[str] | list[dict] = Field( - description="List of items in the beta list, " - + "format depends on item_return_type query parameter", + description="List of items in the beta list, " + "format depends on item_return_type query parameter", + examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], + ) + + +class PatchableItemField(StrEnum): + POWDER = "powder" + + +class ItemPatchSubmission(BaseModel): + client_timestamp: datetime + mod_version: str + patch: PatchableItemField + items: list[str] = Field( + description="List of base64-encoded protobuf bytes for items to add/update", examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], ) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 91763dc..e208cee 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -7,16 +7,14 @@ from .config import BETA_CONFIG from .model import BetaItemRepository -from .schema import NewItemSubmission +from .schema import ItemPatchSubmission, NewItemSubmission, PatchableItemField allowed_version = BETA_CONFIG.allowed_versions async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: if not any(version in submission.mod_version for version in allowed_version): - LOGGER.debug( - f"Submission version {submission.mod_version} is not allowed, skipping submission" - ) + LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission") return itemRepo = BetaItemRepository(session) succeeds = 0 @@ -32,10 +30,7 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe LOGGER.debug(f"Item from submission is identical to existing item: {item.name}") continue if existing and item != existing: - LOGGER.debug( - f"Item from submission is different from existing item: {item.name}," - + " overwriting" - ) + LOGGER.debug(f"Item from submission is different from existing item: {item.name}," + " overwriting") await itemRepo.add_item(item) succeeds += 1 except Exception as e: @@ -73,6 +68,40 @@ async def get_beta_items(session: AsyncSession) -> list[bytes]: return [item.item for item in beta_items] +async def get_beta_items_by_name(session: AsyncSession, name: list[str]) -> list[bytes]: + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.get_items_by_names(name) + return [item.item for item in beta_items] + + +async def handle_patch_submission(submission: ItemPatchSubmission, session: AsyncSession) -> None: + if not any(version in submission.mod_version for version in allowed_version): + LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission") + return + itemRepo = BetaItemRepository(session) + succeeds = 0 + match submission.patch: + case PatchableItemField.POWDER: + for item in submission.items: + try: + item = WynnSourceItem.FromString(b64decode(item)) + existing = await itemRepo.get_item(item.name) + if not existing: + LOGGER.debug(f"Item from patch submission does not exist in beta: {item.name}") + continue + existing_item = WynnSourceItem.FromString(existing.item) + del existing_item.gear.powders[:] + existing_item.gear.powders.extend(item.gear.powders) + await itemRepo.add_item(existing_item) + succeeds += 1 + except Exception as e: + LOGGER.debug(f"Failed to patch item from submission, error: {e}") + # Silently ignore failed items + pass + + LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta patch submission") + + async def handle_delete_beta_items(items: list[str], session: AsyncSession): itemRepo = BetaItemRepository(session) deleted_count = 0 diff --git a/app/module/manage/router.py b/app/module/manage/router.py index 5445dc4..46b5c0c 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -53,7 +53,7 @@ async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]: @ManageRouter.post("/user/register", summary="Register New User") -@metadata.rate_limit(1, 3600) +# @metadata.rate_limit(1, 3600) async def register_user( token: str, session: SessionDep, @@ -95,7 +95,9 @@ async def create_user( await userRepo.save(user) created_tokens.append(UserInfoResponse.model_validate(user)) - LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}") + LOGGER.info( + f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}" + ) return WCSResponse(data=created_tokens) diff --git a/schema b/schema index 15d4474..a55477a 160000 --- a/schema +++ b/schema @@ -1 +1 @@ -Subproject commit 15d4474a48782a96d543860536d0bd97ad5c8c47 +Subproject commit a55477a18aee758d4a289795e84b9961d9c141ad diff --git a/tests/proto/test_proto_codegen.py b/tests/proto/test_proto_codegen.py index 77796fa..c369332 100644 --- a/tests/proto/test_proto_codegen.py +++ b/tests/proto/test_proto_codegen.py @@ -1,5 +1,3 @@ -import base64 - from wynnsource import WynnSourceItem from wynnsource.common.components_pb2 import ( CraftedIdentification, @@ -23,14 +21,14 @@ def test_proto_codegen(): gear=Gear( type=GearType.GEAR_TYPE_SPEAR, requirements=Requirements(level=30, class_req=ClassType.CLASS_TYPE_WARRIOR, defense_req=20), + powders=[ + PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), + PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), + PowderSlot(), + ], crafted=CraftedGear( crafted_meta=CraftedMeta(author="TestCrafter"), durability=Durability(current=232, max=235), - powders=[ - PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), - PowderSlot(powder=Powder(element=Element.ELEMENT_FIRE, level=6)), - PowderSlot(), - ], identifications=[ CraftedIdentification(id=35, current_val=29, max_val=30), CraftedIdentification(id=36, current_val=15, max_val=15), @@ -51,24 +49,3 @@ def test_proto_codegen(): decoded.ParseFromString(encoded) assert item == decoded return encoded - - -def test_proto_decode(): - encoded_b64: str = ( - "ChdDcmFmdGVkIFNwZWFyIG9mIEZsYW1lcyAJUn0IAWJiClcKCVBsYXllcjEyMxJKQSBzcGVhciBm" - + "b3JnZWQgaW4gdGhlIGhlYXJ0IG9mIGEgdm9sY2FubywgaW1idWVkIHdpdGggdGhlIGVzc2VuY2Ugb2YgZmlyZS4aBwgBEBQ" - + "YiCeiARQIAxIGCAEQMhhkEggIBRDIARisAg==" - ) - - decoded = WynnSourceItem() - decoded.ParseFromString(base64.b64decode(encoded_b64)) - assert decoded.name == "Crafted Spear of Flames" - assert decoded.rarity == Rarity.RARITY_CRAFTED - assert decoded.WhichOneof("data") == "gear" - assert decoded.gear.type == GearType.GEAR_TYPE_SPEAR - assert decoded.gear.WhichOneof("state") == "crafted" - assert decoded.gear.crafted.crafted_meta.author == "Player123" - assert ( - decoded.gear.crafted.crafted_meta.lore - == "A spear forged in the heart of a volcano, imbued with the essence of fire." - ) From befd0aa8bd86b5e83d8fd018f23888d0b2d1b650 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:18:13 -0500 Subject: [PATCH 63/78] fix: remove unnecessary min-versions-to-keep parameter from image deletion step --- .github/workflows/deploy.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 65c7df3..1aa9b1d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -29,7 +29,6 @@ jobs: with: package-type: container package-name: wynnsource-server - min-versions-to-keep: 1 num-old-versions-to-delete: 99 delete-only-untagged-versions: true - name: Set up Docker Buildx From 58ae35c0b924b4190bcb228f2009623a305f2337 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:19:53 -0500 Subject: [PATCH 64/78] fix: remove delete-only-untagged-versions parameter from image deletion step --- .github/workflows/deploy.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1aa9b1d..22580d6 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -30,7 +30,6 @@ jobs: package-type: container package-name: wynnsource-server num-old-versions-to-delete: 99 - delete-only-untagged-versions: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push From c5161b6329f161c0ad781182a90aa09a4fed2170 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 18:20:58 +0000 Subject: [PATCH 65/78] chore: update image to 58ae35c0b924b4190bcb228f2009623a305f2337 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 7b4b55a..16eec7a 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 49e596b1446221014ef0291ae3f37d56fd1cb014 + newTag: 58ae35c0b924b4190bcb228f2009623a305f2337 From 88bbc7c33d11569480e2674516b56dee795b7285 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:33:07 -0500 Subject: [PATCH 66/78] fix: merge existing gear powders into new item submissions --- app/module/beta/service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index e208cee..8f86ed5 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -31,6 +31,8 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe continue if existing and item != existing: LOGGER.debug(f"Item from submission is different from existing item: {item.name}," + " overwriting") + if existing and existing.gear.powders: + item.gear.powders.extend(existing.gear.powders) await itemRepo.add_item(item) succeeds += 1 except Exception as e: From 9fde573539b021c8bec626ed16d6d903bbabdcaa Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 4 Mar 2026 13:35:41 -0500 Subject: [PATCH 67/78] fix: remove old image deletion step from deployment workflow --- .github/workflows/deploy.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 22580d6..b5a82f0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -24,12 +24,6 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Delete old images - uses: actions/delete-package-versions@v5 - with: - package-type: container - package-name: wynnsource-server - num-old-versions-to-delete: 99 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push From 4d405e84b6ee7b29dd961282320a2eb8ad13d7ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 18:36:36 +0000 Subject: [PATCH 68/78] chore: update image to 9fde573539b021c8bec626ed16d6d903bbabdcaa --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 16eec7a..3c1c70f 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 58ae35c0b924b4190bcb228f2009623a305f2337 + newTag: 9fde573539b021c8bec626ed16d6d903bbabdcaa From 4a074d114b1a10c62d67235fa019d695fce5bd6d Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 8 Mar 2026 18:05:20 -0400 Subject: [PATCH 69/78] feat: added beta ingredient endpoints --- app/module/beta/router.py | 36 ++++++++++++++++++++++++++++++++ app/module/beta/service.py | 42 +++++++++++++++----------------------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/app/module/beta/router.py b/app/module/beta/router.py index c0181b8..0e4147e 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -9,6 +9,8 @@ from .schema import BetaItemListResponse, ItemPatchSubmission, NewItemSubmission from .service import ( + get_beta_ingredients, + get_beta_ingredients_by_name, get_beta_items, get_beta_items_by_name, handle_clear_beta_items, @@ -50,6 +52,40 @@ async def list_beta_items_filtered( ) +@BetaRouter.get("/ingredients", summary="List Beta Ingredients") +@metadata.rate_limit(limit=10, period=60) +@metadata.cached(expire=30) +async def list_beta_ingredients( + session: SessionDep, + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[BetaItemListResponse]: + """ + List all ingredients in the beta list. + """ + return WCSResponse( + data=BetaItemListResponse(items=item_return_type.format_items(await get_beta_ingredients(session))) + ) + + +@BetaRouter.post("/ingredients/filter", summary="List Beta Ingredients Filtered") +@metadata.rate_limit(limit=10, period=60) +@metadata.cached(expire=30) +async def list_beta_ingredients_filtered( + session: SessionDep, + names: list[str] = Body(default_factory=list, description="Optional list of ingredient names to filter by"), + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[BetaItemListResponse]: + """ + List ingredients in the beta list. + If `names` query parameter is non-empty, only ingredients with matching names will be returned. + """ + return WCSResponse( + data=BetaItemListResponse( + items=item_return_type.format_items(await get_beta_ingredients_by_name(session, names)) + ) + ) + + @BetaRouter.post("/items", summary="Submit new Beta Item") @metadata.rate_limit(limit=300, period=60) async def submit_beta_item(items: NewItemSubmission, session: SessionDep) -> EmptyResponse: diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 8f86ed5..cb1df2c 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -23,9 +23,6 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe item = WynnSourceItem.FromString(b64decode(item)) existing = await itemRepo.get_item(item.name) existing = WynnSourceItem.FromString(existing.item) if existing else None - if not check_item_validity(item): - LOGGER.debug(f"Item from submission is invalid: {item.name}") - continue if existing and item == existing: LOGGER.debug(f"Item from submission is identical to existing item: {item.name}") continue @@ -43,37 +40,32 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta submission") -def check_item_validity(item: WynnSourceItem) -> bool: - if item.name == "": - return False - if item.level == 0: - return False - if item.rarity == 0: - return False - if not item.HasField("gear"): - return False - if item.gear.type == 0: - return False - if not item.gear.HasField("requirements"): - return False - if not item.gear.HasField("unidentified"): - return False - if len(item.gear.unidentified.identifications) == 0: - return False - - return True +async def get_beta_items(session: AsyncSession) -> list[bytes]: + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.list_items() + deserialized_items = [WynnSourceItem.FromString(item.item) for item in beta_items] + return [WynnSourceItem.SerializeToString(item) for item in deserialized_items if item.HasField("gear")] -async def get_beta_items(session: AsyncSession) -> list[bytes]: +async def get_beta_ingredients(session: AsyncSession) -> list[bytes]: itemRepo = BetaItemRepository(session) beta_items = await itemRepo.list_items() - return [item.item for item in beta_items] + deserialized_items = [WynnSourceItem.FromString(item.item) for item in beta_items] + return [WynnSourceItem.SerializeToString(item) for item in deserialized_items if item.HasField("ingredient")] async def get_beta_items_by_name(session: AsyncSession, name: list[str]) -> list[bytes]: itemRepo = BetaItemRepository(session) beta_items = await itemRepo.get_items_by_names(name) - return [item.item for item in beta_items] + deserialized_items = [WynnSourceItem.FromString(item.item) for item in beta_items] + return [WynnSourceItem.SerializeToString(item) for item in deserialized_items if item.HasField("gear")] + + +async def get_beta_ingredients_by_name(session: AsyncSession, name: list[str]) -> list[bytes]: + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.get_items_by_names(name) + deserialized_items = [WynnSourceItem.FromString(item.item) for item in beta_items] + return [WynnSourceItem.SerializeToString(item) for item in deserialized_items if item.HasField("ingredient")] async def handle_patch_submission(submission: ItemPatchSubmission, session: AsyncSession) -> None: From 3ee9cd9fc5a36bff464f8e314d28e3dd91555468 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Mar 2026 22:16:20 +0000 Subject: [PATCH 70/78] chore: update image to 4a074d114b1a10c62d67235fa019d695fce5bd6d --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 3c1c70f..9294364 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 9fde573539b021c8bec626ed16d6d903bbabdcaa + newTag: 4a074d114b1a10c62d67235fa019d695fce5bd6d From d8ee9ea1f19d2d6587eea9b73e05cf04aa77904b Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 8 Mar 2026 21:55:17 -0400 Subject: [PATCH 71/78] feat: add debug endpoint to fix items with weird names --- app/module/beta/router.py | 11 +++++++++++ app/module/beta/service.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/module/beta/router.py b/app/module/beta/router.py index 0e4147e..b9b604f 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -9,6 +9,7 @@ from .schema import BetaItemListResponse, ItemPatchSubmission, NewItemSubmission from .service import ( + fix_items, get_beta_ingredients, get_beta_ingredients_by_name, get_beta_items, @@ -124,3 +125,13 @@ async def clear_beta_items(session: SessionDep) -> EmptyResponse: """ await handle_clear_beta_items(session) return EmptyResponse() + + +@BetaRouter.get("debug/fix", summary="Fix item with weird names", include_in_schema=False) +@metadata.permission("beta.items.debug") +async def fix_weird_items(session: SessionDep): + """ + Fix items with weird names that may have been caused by encoding issues. + """ + + return WCSResponse(data={"fixed": await fix_items(session)}) diff --git a/app/module/beta/service.py b/app/module/beta/service.py index cb1df2c..10408a8 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -21,6 +21,8 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe for item in submission.items: try: item = WynnSourceItem.FromString(b64decode(item)) + while item.name.endswith("À"): + item.name = item.name.removesuffix("À") existing = await itemRepo.get_item(item.name) existing = WynnSourceItem.FromString(existing.item) if existing else None if existing and item == existing: @@ -123,3 +125,21 @@ async def handle_clear_beta_items(session: AsyncSession): # Silently ignore failed deletions pass LOGGER.info(f"Cleared {deleted_count} items from beta") + + +async def fix_items(session: AsyncSession) -> int: + itemRepo = BetaItemRepository(session) + beta_items = await itemRepo.list_items() + fixed_count = 0 + for item in beta_items: + try: + existing = WynnSourceItem.FromString(item.item) + while existing.name.endswith("À"): + existing.name = existing.name.removesuffix("À") + + await itemRepo.add_item(existing) + fixed_count += 1 + except Exception: + continue + + return fixed_count From c81138fb6a2044afb5d3b0fd0d98f30a80680c5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 01:57:43 +0000 Subject: [PATCH 72/78] chore: update image to d8ee9ea1f19d2d6587eea9b73e05cf04aa77904b --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 9294364..114a3ed 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: 4a074d114b1a10c62d67235fa019d695fce5bd6d + newTag: d8ee9ea1f19d2d6587eea9b73e05cf04aa77904b From 24e0b31d4dcd0b1ac8d984fe741584556a6b8703 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Sun, 8 Mar 2026 22:05:38 -0400 Subject: [PATCH 73/78] fix: correct endpoint path and enhance item fixing logic --- app/module/beta/router.py | 2 +- app/module/beta/service.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/module/beta/router.py b/app/module/beta/router.py index b9b604f..ae3e9c7 100644 --- a/app/module/beta/router.py +++ b/app/module/beta/router.py @@ -127,7 +127,7 @@ async def clear_beta_items(session: SessionDep) -> EmptyResponse: return EmptyResponse() -@BetaRouter.get("debug/fix", summary="Fix item with weird names", include_in_schema=False) +@BetaRouter.get("/debug/fix", summary="Fix item with weird names", include_in_schema=False) @metadata.permission("beta.items.debug") async def fix_weird_items(session: SessionDep): """ diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 10408a8..8a711c3 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -134,11 +134,14 @@ async def fix_items(session: AsyncSession) -> int: for item in beta_items: try: existing = WynnSourceItem.FromString(item.item) - while existing.name.endswith("À"): - existing.name = existing.name.removesuffix("À") - - await itemRepo.add_item(existing) - fixed_count += 1 + previous_name = existing.name + if existing.name.endswith("À"): + while existing.name.endswith("À"): + existing.name = existing.name.removesuffix("À") + + await itemRepo.add_item(existing) + await itemRepo.delete_item(previous_name) + fixed_count += 1 except Exception: continue From 289127f3e0f957fd140d28ccaaf89ae4f2681564 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 02:06:35 +0000 Subject: [PATCH 74/78] chore: update image to 24e0b31d4dcd0b1ac8d984fe741584556a6b8703 --- deploy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index 114a3ed..1a8eebe 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: ghcr.io/wynnsource/wynnsource-server newName: ghcr.io/wynnsource/wynnsource-server - newTag: d8ee9ea1f19d2d6587eea9b73e05cf04aa77904b + newTag: 24e0b31d4dcd0b1ac8d984fe741584556a6b8703 From 102d43890bbca5ad3f5b0c0219860347d2d02247 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 8 Apr 2026 19:27:26 -0400 Subject: [PATCH 75/78] feat: Implement raid gambits feature with submission and consensus calculation - Added new models for Gambit and GambitSubmission to handle raid gambits. - Created API endpoints for submitting gambit data and retrieving current consensus. - Introduced a scheduler for periodic consensus recalculation. - Updated database schema with migrations for new tables related to gambits. - Added validation for gambit submissions to prevent duplicate names. - Implemented logging and error handling for submission processes. - Removed deprecated deployment and configuration files, and added new base configurations. - Set up CI/CD workflows for building and pushing Docker images to GHCR. --- .github/workflows/ci.yaml | 64 ++++++++ .github/workflows/deploy.yaml | 49 ------ .github/workflows/ghcr-cleanup.yaml | 33 ++++ app/core/db/models.py | 3 +- app/core/db/session.py | 4 +- app/core/score.py | 127 ++++++++++++++- app/core/security/model.py | 7 + app/module/api/router.py | 12 +- app/module/api/service.py | 28 ++-- app/module/manage/router.py | 6 +- app/module/pool/schema.py | 14 +- app/module/raid/config.py | 42 +++++ app/module/raid/model.py | 147 ++++++++++++++++++ app/module/raid/router.py | 92 +++++++++++ app/module/raid/schema.py | 44 ++++++ app/module/raid/service.py | 138 ++++++++++++++++ app/schemas/enums/tag.py | 1 + deploy/{ => base}/deployment.yaml | 2 +- deploy/base/kustomization.yaml | 8 + deploy/{ => base}/migration-job.yaml | 2 +- deploy/{ => base}/postgres.yaml | 0 deploy/{ => base}/redis.yaml | 0 deploy/{ => base}/service.yaml | 0 deploy/configmap.yaml | 7 - deploy/kustomization.yaml | 14 -- deploy/overlays/dev/kustomization.yaml | 8 + deploy/overlays/prod/kustomization.yaml | 16 ++ .../8129be0ee115_added_raid_gambits.py | 63 ++++++++ schema | 2 +- test_pydantic.py | 44 ------ 30 files changed, 816 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/deploy.yaml create mode 100644 .github/workflows/ghcr-cleanup.yaml create mode 100644 app/module/raid/config.py create mode 100644 app/module/raid/model.py create mode 100644 app/module/raid/router.py create mode 100644 app/module/raid/schema.py create mode 100644 app/module/raid/service.py rename deploy/{ => base}/deployment.yaml (97%) create mode 100644 deploy/base/kustomization.yaml rename deploy/{ => base}/migration-job.yaml (95%) rename deploy/{ => base}/postgres.yaml (100%) rename deploy/{ => base}/redis.yaml (100%) rename deploy/{ => base}/service.yaml (100%) delete mode 100644 deploy/configmap.yaml delete mode 100644 deploy/kustomization.yaml create mode 100644 deploy/overlays/dev/kustomization.yaml create mode 100644 deploy/overlays/prod/kustomization.yaml create mode 100644 migration/versions/8129be0ee115_added_raid_gambits.py delete mode 100644 test_pydantic.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c4e0aa7 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [dev] + tags: ["v*"] + pull_request: + branches: [dev, master] + +env: + REGISTRY: ghcr.io + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + + build-and-push: + needs: lint + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/wynnsource-server + tags: | + type=sha,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }} + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index b5a82f0..0000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: Build & Deploy -on: - workflow_dispatch: - push: - tags: - - "v*.*.*" -env: - REGISTRY: ghcr.io -jobs: - build-and-update: - runs-on: ubuntu-latest - permissions: - contents: write - packages: write - steps: - - name: Set image name - run: echo "IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/wynnsource-server" >> $GITHUB_ENV - - uses: actions/checkout@v4 - with: - submodules: recursive - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - name: Update image tag in deploy/ - run: | - cd deploy - kustomize edit set image \ - ghcr.io/${{ env.IMAGE_NAME }}=ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }} - - name: Commit updated kustomization - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add deploy/kustomization.yaml - git diff --cached --quiet || git commit -m "chore: update image to ${{ github.sha }}" && git push diff --git a/.github/workflows/ghcr-cleanup.yaml b/.github/workflows/ghcr-cleanup.yaml new file mode 100644 index 0000000..64d1e47 --- /dev/null +++ b/.github/workflows/ghcr-cleanup.yaml @@ -0,0 +1,33 @@ +name: GHCR Cleanup + +on: + schedule: + - cron: "0 3 * * 0" # Every Sunday at 03:00 UTC + workflow_dispatch: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Clean up dev images + uses: snok/container-retention-policy@v3 + with: + account: ${{ github.repository_owner }} + token: ${{ secrets.GITHUB_TOKEN }} + image-names: wynnsource-server + cut-off: 7 days ago UTC + keep-n-most-recent: 5 + filter-tags: "dev-*" + skip-tags: "dev-latest" + image-tags: "!latest" + + - name: Clean up untagged manifests + uses: snok/container-retention-policy@v3 + with: + account: ${{ github.repository_owner }} + token: ${{ secrets.GITHUB_TOKEN }} + image-names: wynnsource-server + cut-off: 1 day ago UTC + untagged-only: true diff --git a/app/core/db/models.py b/app/core/db/models.py index 0a6cf3b..2f1dafd 100644 --- a/app/core/db/models.py +++ b/app/core/db/models.py @@ -5,6 +5,7 @@ import app.core.security.model import app.module.beta.model -import app.module.pool.model # noqa: F401 +import app.module.pool.model +import app.module.raid.model # noqa: F401 __all__ = [] diff --git a/app/core/db/session.py b/app/core/db/session.py index 954c40f..24b3d59 100644 --- a/app/core/db/session.py +++ b/app/core/db/session.py @@ -12,7 +12,7 @@ session_maker: async_sessionmaker[AsyncSession] -async def get_dsn() -> str: +def get_dsn() -> str: """ Returns the database connection string based on the configuration. """ @@ -24,7 +24,7 @@ async def get_dsn() -> str: async def init_db() -> AsyncEngine: global engine, session_maker - dsn = await get_dsn() + dsn = get_dsn() engine = create_async_engine(dsn, echo=False) session_maker = async_sessionmaker(engine, expire_on_commit=False) LOGGER.debug(f"Database engine initialized with DSN: {dsn}") diff --git a/app/core/score.py b/app/core/score.py index f6ea277..b98530e 100644 --- a/app/core/score.py +++ b/app/core/score.py @@ -1,10 +1,23 @@ +import datetime +from collections import Counter from dataclasses import dataclass from enum import Enum +from typing import TYPE_CHECKING from apscheduler.triggers.cron import CronTrigger from app.core.scheduler import SCHEDULER +if TYPE_CHECKING: + from app.module.pool.model import Pool + +# Scoring configuration +QUALITY_WEIGHT = 0.7 +ACTIVITY_WEIGHT = 0.3 +ACTIVITY_THRESHOLD = 0.5 +SCORE_MIN = -10000 +SCORE_MAX = 10000 + @dataclass class ScoreRange: @@ -91,10 +104,112 @@ def score_to_next_tier(self, score: int) -> int: coalesce=True, ) async def update_user_scores(): - pass - - -async def calculate_quality_factor(): ... - + # Lazy imports to avoid circular dependency (pool/config.py imports Tier) + from app.core.db import get_session + from app.core.log import LOGGER + from app.core.security.model import UserRepository + from app.module.pool.config import POOL_REFRESH_CONFIG + from app.module.pool.model import PoolRepository + from app.module.pool.schema import PoolType + + async with get_session() as session: + # 1. Fetch all current rotation pools across all pool types + all_current_pools: list[Pool] = [] + now = datetime.datetime.now(tz=datetime.UTC) + for pool_type in PoolType: + rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation(now) + pool_repo = PoolRepository(session) + pools = await pool_repo.list_pools( + pool_type=pool_type, + rotation_start=rotation.start, + ) + all_current_pools.extend(pools) + + if not all_current_pools: + LOGGER.info("No active pools for current rotation, skipping score update") + return + + # 2. Collect all unique user_ids who submitted to any current pool + user_ids: set[int] = set() + for pool in all_current_pools: + for sub in pool.submissions: + user_ids.add(sub.user_id) + + if not user_ids: + LOGGER.info("No submissions found for current rotation, skipping score update") + return + + # 3. Load users + user_repo = UserRepository(session) + users = await user_repo.get_users_by_ids(list(user_ids)) + + # 4. Calculate and apply score deltas + for user in users: + quality = calculate_quality_factor(user.id, all_current_pools) + activity = calculate_activity_factor(user.id, all_current_pools) + + tier = Tier.get_by_score(user.score) + delta = tier.daily_base * (quality * QUALITY_WEIGHT + activity * ACTIVITY_WEIGHT) + delta = int(round(delta)) + delta = max(-tier.daily_base, min(tier.daily_base, delta)) + + new_score = max(SCORE_MIN, min(SCORE_MAX, user.score + delta)) + user.score = new_score + + LOGGER.debug( + f"User {user.id}: quality={quality:.3f}, activity={activity:.3f}, " + f"delta={delta}, new_score={new_score}" + ) + + LOGGER.info(f"Updated scores for {len(users)} users") + + +def calculate_quality_factor(user_id: int, pools: list["Pool"]) -> float: + """Compare user submissions against consensus. Returns [-1.0, 1.0].""" + pool_qualities: list[float] = [] + + for pool in pools: + if not pool.consensus_data or pool.confidence <= 0: + continue + + user_sub = None + for sub in pool.submissions: + if sub.user_id == user_id: + user_sub = sub + break + + if user_sub is None: + continue + + user_items = Counter(user_sub.item_data) + consensus_items = Counter(pool.consensus_data) + + all_keys = set(user_items.keys()) | set(consensus_items.keys()) + if not all_keys: + continue + + intersection = sum(min(user_items[k], consensus_items[k]) for k in all_keys) + union = sum(max(user_items[k], consensus_items[k]) for k in all_keys) + + similarity = intersection / union if union > 0 else 0.0 + pool_quality = (2.0 * similarity - 1.0) * pool.confidence + pool_qualities.append(pool_quality) + + if not pool_qualities: + return 0.0 + + return sum(pool_qualities) / len(pool_qualities) + + +def calculate_activity_factor(user_id: int, pools: list["Pool"]) -> float: + """Measure participation breadth. Returns [0.0, 1.0].""" + total_pools = len(pools) + if total_pools == 0: + return 0.0 + + user_pools = sum( + 1 for pool in pools if any(sub.user_id == user_id for sub in pool.submissions) + ) -async def calculate_activity_factor(): ... + ratio = user_pools / total_pools + return min(1.0, ratio / ACTIVITY_THRESHOLD) diff --git a/app/core/security/model.py b/app/core/security/model.py index d17b996..ad78cbe 100644 --- a/app/core/security/model.py +++ b/app/core/security/model.py @@ -61,6 +61,13 @@ async def delete(self, token_strs: list[str]) -> None: await self.session.execute(delete(User).where(User.token.in_(token_strs))) await self.session.flush() + async def get_users_by_ids(self, user_ids: list[int]) -> list[User]: + if not user_ids: + return [] + query = select(User).where(User.id.in_(user_ids)) + result = await self.session.execute(query) + return list(result.scalars().all()) + async def update_user_ip(self, user: User, ip: str) -> None: if ip not in user.common_ips: if len(user.common_ips) >= USER_CONFIG.max_ip_records: diff --git a/app/module/api/router.py b/app/module/api/router.py index 886e7e2..ec036b7 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -6,19 +6,19 @@ from app.module.manage.router import ManageRouter from app.module.market.router import MarketRouter from app.module.pool.router import PoolRouter +from app.module.raid.router import RaidRouter from app.schemas.enums import ApiTag, ItemReturnType from app.schemas.response import StatusResponse, WCSResponse from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse from .service import MappingStorage, generate_random_item -Router = APIRouter( - route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}} -) +Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) Router.include_router(ManageRouter) Router.include_router(PoolRouter) Router.include_router(MarketRouter) Router.include_router(BetaRouter) +Router.include_router(RaidRouter) @Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC]) @@ -40,7 +40,7 @@ async def get_mappings(mapping_type: MappingType) -> MappingResponse: Get ID mappings for a given type. """ - storage = MappingStorage().get_instance() + storage = MappingStorage.get_instance() return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore") @@ -52,6 +52,4 @@ async def get_random_item( """ Get a random item for testing purposes. """ - return WCSResponse( - data=RandomItemResponse(item=item_return_type.format_item(generate_random_item())) - ) + return WCSResponse(data=RandomItemResponse(item=item_return_type.format_item(generate_random_item()))) diff --git a/app/module/api/service.py b/app/module/api/service.py index 26e4129..bbf54fd 100644 --- a/app/module/api/service.py +++ b/app/module/api/service.py @@ -1,4 +1,5 @@ import random +from typing import ClassVar import httpx import jsonschema @@ -23,15 +24,17 @@ class MappingStorage: Singleton storage class for ID to name mappings. """ + _instance: ClassVar["MappingStorage | None"] = None mappings: dict[MappingType, dict] def __init__(self): self.mappings = {} - def get_instance(self) -> "MappingStorage": - if not hasattr(self, "_instance"): - self._instance = MappingStorage() - return self._instance + @classmethod + def get_instance(cls) -> "MappingStorage": + if cls._instance is None: + cls._instance = MappingStorage() + return cls._instance async def get_mapping(self, mapping_type: MappingType) -> dict: if mapping_type in self.mappings: @@ -46,8 +49,7 @@ async def update_mapping(self, mapping_type: MappingType): response = await client.get(url) if response.status_code != 200: LOGGER.error( - f"Failed to fetch mapping for {mapping_type.value} " - + "from {url}: {response.status_code}" + f"Failed to fetch mapping for {mapping_type.value} " + "from {url}: {response.status_code}" ) return schema_response = await client.get(schema_url) @@ -64,14 +66,10 @@ async def update_mapping(self, mapping_type: MappingType): try: jsonschema.validate(instance=mapping, schema=schema) except jsonschema.ValidationError as e: - LOGGER.error( - f"Mapping data for {mapping_type.value} failed schema validation: {e.message}" - ) + LOGGER.error(f"Mapping data for {mapping_type.value} failed schema validation: {e.message}") return - del mapping[ - SCHEMA_FIELD - ] # we don't need the schema reference as it's relative path in the repo + del mapping[SCHEMA_FIELD] # we don't need the schema reference as it's relative path in the repo self.mappings[mapping_type] = mapping @@ -85,7 +83,7 @@ async def update_mapping(): """ Scheduled job to update ID to name mappings every hour. """ - storage = MappingStorage().get_instance() + storage = MappingStorage.get_instance() for mapping_type in MappingType: await storage.update_mapping(mapping_type) @@ -95,6 +93,4 @@ def generate_random_item() -> bytes: Generate a random item for testing purposes. """ id = random.randint(1, 10000) - return WynnSourceItem( - name=f"Test Item {id}", level=id % 100, rarity=RARITY_CRAFTED - ).SerializeToString() + return WynnSourceItem(name=f"Test Item {id}", level=id % 100, rarity=RARITY_CRAFTED).SerializeToString() diff --git a/app/module/manage/router.py b/app/module/manage/router.py index 46b5c0c..5445dc4 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -53,7 +53,7 @@ async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]: @ManageRouter.post("/user/register", summary="Register New User") -# @metadata.rate_limit(1, 3600) +@metadata.rate_limit(1, 3600) async def register_user( token: str, session: SessionDep, @@ -95,9 +95,7 @@ async def create_user( await userRepo.save(user) created_tokens.append(UserInfoResponse.model_validate(user)) - LOGGER.info( - f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}" - ) + LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}") return WCSResponse(data=created_tokens) diff --git a/app/module/pool/schema.py b/app/module/pool/schema.py index a01b541..13c2b2a 100644 --- a/app/module/pool/schema.py +++ b/app/module/pool/schema.py @@ -16,6 +16,8 @@ class LootPoolRegion(enum.StrEnum): SE = "SE" COTL = "Canyon" CORKUS = "Corkus" + FrumaWest = "FrumaWest" + FrumaEast = "FrumaEast" class Rarity(enum.StrEnum): @@ -32,6 +34,7 @@ class RaidRegion(enum.StrEnum): TCC = "TCC" NOL = "NOL" NOTG = "NOTG" + TWP = "TWP" VALID_REGIONS: dict[PoolType, type[enum.StrEnum]] = { @@ -43,9 +46,7 @@ class RaidRegion(enum.StrEnum): class PoolSubmissionSchema(BaseModel): pool_type: PoolType - region: str = Field( - description="Region name, must be one of the valid regions for the pool type" - ) + region: str = Field(description="Region name, must be one of the valid regions for the pool type") page: int client_timestamp: datetime mod_version: str @@ -57,9 +58,7 @@ class PoolSubmissionSchema(BaseModel): class PoolConsensusResponse(BaseModel): pool_type: PoolType - region: str = Field( - description="Region name, must be one of the valid regions for the pool type" - ) + region: str = Field(description="Region name, must be one of the valid regions for the pool type") rotation_start: datetime rotation_end: datetime page_consensus: list["PageConsensus"] @@ -67,8 +66,7 @@ class PoolConsensusResponse(BaseModel): class PageConsensus(BaseModel): page: int = Field(description="The page number") items: list[str] | list[dict] = Field( - description="Consensus item data for the page, " - + "format depends on item_return_type query parameter", + description="Consensus item data for the page, " + "format depends on item_return_type query parameter", examples=[["aXRlbV9kYXRhXzE=", "aXRlbV9kYXRhXzI="]], ) confidence: float = Field(description="Confidence level, between 0 and 1") diff --git a/app/module/raid/config.py b/app/module/raid/config.py new file mode 100644 index 0000000..49c8cc5 --- /dev/null +++ b/app/module/raid/config.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +SERVER_TZ = ZoneInfo("America/New_York") + +GAMBIT_COUNT = 4 +GAMBIT_SEPARATOR = "|" +FUZZY_WINDOW = timedelta(minutes=30) +CONSENSUS_THRESHOLD = 0.6 + + +@dataclass +class GambitRotation: + start: datetime + end: datetime + + +def get_gambit_rotation(time: datetime, shift: int = 0) -> GambitRotation: + """Get the daily gambit rotation window for the given timestamp. + + Gambits rotate daily at EST/EDT midnight (00:00 America/New_York). + """ + if time.tzinfo is None: + raise ValueError("The 'time' parameter must be timezone-aware.") + + local_time = time.astimezone(SERVER_TZ) + + # Today's reset at midnight EST + today_reset = datetime.combine( + local_time.date(), datetime.min.time(), tzinfo=SERVER_TZ + ) + + if local_time < today_reset: + today_reset -= timedelta(days=1) + + if shift != 0: + today_reset += timedelta(days=shift) + + next_reset = today_reset + timedelta(days=1) + + return GambitRotation(start=today_reset, end=next_reset) diff --git a/app/module/raid/model.py b/app/module/raid/model.py new file mode 100644 index 0000000..cb765b0 --- /dev/null +++ b/app/module/raid/model.py @@ -0,0 +1,147 @@ +from datetime import datetime +from typing import Literal + +from sqlalchemy import ( + ARRAY, + Boolean, + DateTime, + Float, + ForeignKey, + String, + UniqueConstraint, + func, + select, +) +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload + +from app.core.db import Base, BaseRepository +from app.core.security.model import User + +from .config import GambitRotation + + +class Gambit(Base): + __tablename__ = "gambits" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + region: Mapped[str] = mapped_column(String(50), nullable=False) + + rotation_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + rotation_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + consensus_data: Mapped[list[str]] = mapped_column(ARRAY(String(255)), nullable=False, default=[]) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_updated: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=datetime.now + ) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + + needs_recalc: Mapped[bool] = mapped_column(Boolean, default=True) + + submissions: Mapped[list["GambitSubmission"]] = relationship( + "GambitSubmission", + back_populates="gambit", + cascade="save-update, merge", + ) + + __table_args__ = (UniqueConstraint("region", "rotation_start", name="uq_gambit_key"),) + + +class GambitSubmission(Base): + __tablename__ = "gambit_submissions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + gambit_id: Mapped[int | None] = mapped_column( + ForeignKey("gambits.id", ondelete="CASCADE"), index=True, nullable=True + ) + gambit: Mapped[Gambit | None] = relationship("Gambit", back_populates="submissions") + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + user: Mapped[User] = relationship("User") + + submitted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + client_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + mod_version: Mapped[str] = mapped_column(String(50), nullable=False) + + # Each gambit is (name, description), stored as parallel arrays + gambit_names: Mapped[list[str]] = mapped_column(ARRAY(String(255)), nullable=False) + gambit_descriptions: Mapped[list[str]] = mapped_column(ARRAY(String(255)), nullable=False) + + weight: Mapped[float] = mapped_column(Float, nullable=False) + + +class GambitRepository(BaseRepository): + async def get_by_key(self, region: str, rotation_start: datetime) -> Gambit | None: + query = select(Gambit).where( + Gambit.region == region, + Gambit.rotation_start == rotation_start, + ) + result = await self.session.execute(query) + return result.scalars().first() + + async def list_gambits( + self, + region: str | None = None, + rotation_start: datetime | None = None, + needs_recalc: bool | None = None, + order_by: Literal["rotation_start"] | None = None, + ) -> list[Gambit]: + query = select(Gambit).options(selectinload(Gambit.submissions)) + if region is not None: + query = query.where(Gambit.region == region) + if rotation_start is not None: + query = query.where(Gambit.rotation_start == rotation_start) + if needs_recalc is not None: + query = query.where(Gambit.needs_recalc == needs_recalc) + + if order_by == "rotation_start": + query = query.order_by(Gambit.rotation_start) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def save(self, gambit: Gambit) -> None: + self.session.add(gambit) + await self.session.flush() + await self.session.refresh(gambit) + + async def get_or_create_gambit(self, region: str, rotation: GambitRotation) -> Gambit: + existing = await self.get_by_key(region=region, rotation_start=rotation.start) + if existing: + return existing + + try: + async with self.session.begin_nested(): + new_gambit = Gambit( + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + ) + await self.save(new_gambit) + return new_gambit + except IntegrityError: + return await self.get_or_create_gambit(region, rotation) + + +class GambitSubmissionRepository(BaseRepository): + async def save(self, submission: GambitSubmission) -> None: + self.session.add(submission) + await self.session.flush() + await self.session.refresh(submission) + + async def delete(self, submission: GambitSubmission) -> None: + await self.session.delete(submission) + await self.session.flush() + + async def get_user_submission_for_gambit(self, user_id: int, gambit_id: int) -> GambitSubmission | None: + query = select(GambitSubmission).where( + GambitSubmission.user_id == user_id, + GambitSubmission.gambit_id == gambit_id, + ) + result = await self.session.execute(query) + return result.scalars().first() diff --git a/app/module/raid/router.py b/app/module/raid/router.py new file mode 100644 index 0000000..d6d8cff --- /dev/null +++ b/app/module/raid/router.py @@ -0,0 +1,92 @@ +import datetime + +from fastapi import APIRouter, HTTPException +from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT + +from app.core import metadata +from app.core.db import SessionDep, get_session +from app.core.rate_limiter import ip_based_key_func, user_based_key_func +from app.core.router import DocedAPIRoute +from app.core.security.auth import UserDep +from app.module.pool.schema import RaidRegion +from app.schemas.enums import ApiTag +from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse + +from .config import get_gambit_rotation +from .schema import GambitConsensusEntry, GambitConsensusResponse, GambitSubmissionSchema +from .service import compute_gambit_consensus, get_gambit_consensus +from .service import submit_gambit_data as svc_submit_gambit_data + +RaidRouter = APIRouter(route_class=DocedAPIRoute, prefix="/raid", tags=[ApiTag.RAID]) + + +@RaidRouter.get("/gambit/recalc", summary="Force Recalculate Gambit Consensus") +@metadata.permission("raid.recalc") +async def recalculate_gambits() -> EmptyResponse: + """ + Force recalculate gambit consensus. + """ + await compute_gambit_consensus() + return EMPTY_RESPONSE + + +@RaidRouter.post("/gambit/submit", summary="Submit Gambit Data") +@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) +async def submit_gambit_data(data: list[GambitSubmissionSchema], user: UserDep) -> EmptyResponse: + """ + Submit gambit data for the current rotation. + Partial submissions are supported (1-4 gambits per region). + """ + for submission in data: + try: + async with get_session() as session: + await svc_submit_gambit_data(session, submission, user) + except ValueError: + continue + + return EMPTY_RESPONSE + + +@RaidRouter.get("/gambit/{region}", summary="Get Current Gambit Consensus") +@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func) +@metadata.cached(expire=120) +async def get_gambit_by_region( + region: RaidRegion, + session: SessionDep, +) -> WCSResponse[GambitConsensusResponse]: + """ + Get gambit consensus data for a raid region. + """ + try: + rotation = get_gambit_rotation(datetime.datetime.now(tz=datetime.UTC)) + result = await get_gambit_consensus(session, region, rotation.start) + + if result is None: + data = GambitConsensusResponse( + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + gambits=[], + confidence=0.0, + ) + else: + pairs, confidence = result + data = GambitConsensusResponse( + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + gambits=[ + GambitConsensusEntry( + name=name, + description=desc, + confidence=confidence, + ) + for name, desc in pairs + ], + confidence=confidence, + ) + + except ValueError as e: + raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) + + return WCSResponse(data=data) diff --git a/app/module/raid/schema.py b/app/module/raid/schema.py new file mode 100644 index 0000000..d954d07 --- /dev/null +++ b/app/module/raid/schema.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, model_validator + +from app.module.pool.schema import RaidRegion + +from .config import GAMBIT_COUNT + + +class GambitEntry(BaseModel): + name: str = Field(description="Gambit name", max_length=255) + description: str = Field(description="Gambit description", max_length=255) + + +class GambitSubmissionSchema(BaseModel): + region: RaidRegion + client_timestamp: datetime + mod_version: str + gambits: list[GambitEntry] = Field( + description="List of gambits (partial submission allowed, up to 4)", + min_length=1, + max_length=GAMBIT_COUNT, + ) + + @model_validator(mode="after") + def check_no_duplicate_names(self) -> "GambitSubmissionSchema": + names = [g.name for g in self.gambits] + if len(names) != len(set(names)): + raise ValueError("Duplicate gambit names are not allowed") + return self + + +class GambitConsensusEntry(BaseModel): + name: str + description: str + confidence: float = Field(description="Confidence for this gambit entry") + + +class GambitConsensusResponse(BaseModel): + region: str + rotation_start: datetime + rotation_end: datetime + gambits: list[GambitConsensusEntry] + confidence: float = Field(description="Overall confidence level") diff --git a/app/module/raid/service.py b/app/module/raid/service.py new file mode 100644 index 0000000..c546a82 --- /dev/null +++ b/app/module/raid/service.py @@ -0,0 +1,138 @@ +import datetime +from collections import defaultdict + +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_session +from app.core.scheduler import SCHEDULER +from app.core.security.model import User +from app.module.pool.schema import RaidRegion +from app.module.pool.service import calculate_submission_weight + +from .config import FUZZY_WINDOW, GAMBIT_COUNT, GAMBIT_SEPARATOR, get_gambit_rotation +from .model import GambitRepository, GambitSubmission, GambitSubmissionRepository +from .schema import GambitSubmissionSchema + + +async def submit_gambit_data(session: AsyncSession, data: GambitSubmissionSchema, user: User): + # Timestamp validation (±10 minutes) + now = datetime.datetime.now(tz=datetime.UTC) + if abs(now - data.client_timestamp) > datetime.timedelta(minutes=10): + raise ValueError("Client timestamp is too far from server time") + + gambit_repo = GambitRepository(session) + submission_repo = GambitSubmissionRepository(session) + + rotation = get_gambit_rotation(data.client_timestamp) + gambit = await gambit_repo.get_or_create_gambit( + region=data.region.value, + rotation=rotation, + ) + + fuzzy = ( + abs(gambit.rotation_start - data.client_timestamp) < FUZZY_WINDOW + or abs(gambit.rotation_end - data.client_timestamp) < FUZZY_WINDOW + ) + + submission = GambitSubmission( + user_id=user.id, + client_timestamp=data.client_timestamp, + mod_version=data.mod_version, + gambit_names=[g.name for g in data.gambits], + gambit_descriptions=[g.description for g in data.gambits], + weight=calculate_submission_weight(user, fuzzy), + ) + + # One submission per user per gambit rotation + existing = await submission_repo.get_user_submission_for_gambit(user.id, gambit.id) + if existing is not None: + await submission_repo.delete(existing) + + submission.gambit = gambit + gambit.needs_recalc = True + await submission_repo.save(submission) + + +@SCHEDULER.scheduled_job( + IntervalTrigger(minutes=20), + id="compute_gambit_consensus", + misfire_grace_time=60, + coalesce=True, +) +async def compute_gambit_consensus(): + async with get_session() as session: + gambit_repo = GambitRepository(session) + rotation = get_gambit_rotation(datetime.datetime.now(tz=datetime.UTC)) + active_gambits = await gambit_repo.list_gambits( + rotation_start=rotation.start, + needs_recalc=True, + ) + + for gambit in active_gambits: + submissions = gambit.submissions + + if not submissions: + gambit.consensus_data = [] + gambit.confidence = 0.0 + gambit.needs_recalc = False + continue + + # Aggregate weighted votes per slot position (0-3) + # Users submit the first N gambits in order, so slot index is meaningful + # slot_weights[slot]["name|desc"] = total_weight + slot_weights: list[dict[str, float]] = [defaultdict(float) for _ in range(GAMBIT_COUNT)] + + for sub in submissions: + for slot, (name, desc) in enumerate(zip(sub.gambit_names, sub.gambit_descriptions)): + key = f"{name}{GAMBIT_SEPARATOR}{desc}" + slot_weights[slot][key] += sub.weight + + # For each slot, pick the entry with the highest weight (if any votes exist) + consensus_data: list[str] = [] + slot_confidences: list[float] = [] + + for slot in range(GAMBIT_COUNT): + if not slot_weights[slot]: + break # No more data beyond this slot + + best_entry = max(slot_weights[slot], key=slot_weights[slot].__getitem__) + best_weight = slot_weights[slot][best_entry] + total_slot_weight = sum(slot_weights[slot].values()) + + consensus_data.append(best_entry) + slot_confidences.append(best_weight / total_slot_weight if total_slot_weight > 0 else 0.0) + + gambit.consensus_data = consensus_data + gambit.confidence = round( + sum(slot_confidences) / len(slot_confidences) if slot_confidences else 0.0, 4 + ) + gambit.needs_recalc = False + + +async def get_gambit_consensus( + session: AsyncSession, + region: RaidRegion, + rotation_start: datetime.datetime, +) -> tuple[list[tuple[str, str]], float] | None: + """Returns ([(name, description), ...], confidence) or None if no data.""" + gambit_repo = GambitRepository(session) + gambits = await gambit_repo.list_gambits( + region=region.value, + rotation_start=rotation_start, + ) + + if not gambits: + return None + + gambit = gambits[0] + if not gambit.consensus_data: + return [], gambit.confidence + + # Decode "name|description" entries + pairs: list[tuple[str, str]] = [] + for entry in gambit.consensus_data: + name, _, desc = entry.partition(GAMBIT_SEPARATOR) + pairs.append((name, desc)) + + return pairs, gambit.confidence diff --git a/app/schemas/enums/tag.py b/app/schemas/enums/tag.py index abfcdc6..a453cb4 100644 --- a/app/schemas/enums/tag.py +++ b/app/schemas/enums/tag.py @@ -7,3 +7,4 @@ class ApiTag(StrEnum): MARKET = "market" BETA = "beta" MISC = "misc" + RAID = "raid" diff --git a/deploy/deployment.yaml b/deploy/base/deployment.yaml similarity index 97% rename from deploy/deployment.yaml rename to deploy/base/deployment.yaml index 71d028d..7c76d8b 100644 --- a/deploy/deployment.yaml +++ b/deploy/base/deployment.yaml @@ -20,7 +20,7 @@ spec: runAsNonRoot: true containers: - name: server - image: ghcr.io/wynnsource/wynnsource-server:latest + image: ghcr.io/wynnsource/wynnsource-server:placeholder ports: - containerPort: 8000 name: http diff --git a/deploy/base/kustomization.yaml b/deploy/base/kustomization.yaml new file mode 100644 index 0000000..6ec5fc4 --- /dev/null +++ b/deploy/base/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - postgres.yaml + - redis.yaml + - migration-job.yaml + - deployment.yaml + - service.yaml diff --git a/deploy/migration-job.yaml b/deploy/base/migration-job.yaml similarity index 95% rename from deploy/migration-job.yaml rename to deploy/base/migration-job.yaml index 331898d..f6cafac 100644 --- a/deploy/migration-job.yaml +++ b/deploy/base/migration-job.yaml @@ -12,7 +12,7 @@ spec: spec: containers: - name: migration - image: ghcr.io/wynnsource/wynnsource-server:latest + image: ghcr.io/wynnsource/wynnsource-server:placeholder command: ["uv", "run", "--no-dev", "alembic", "upgrade", "head"] envFrom: - secretRef: diff --git a/deploy/postgres.yaml b/deploy/base/postgres.yaml similarity index 100% rename from deploy/postgres.yaml rename to deploy/base/postgres.yaml diff --git a/deploy/redis.yaml b/deploy/base/redis.yaml similarity index 100% rename from deploy/redis.yaml rename to deploy/base/redis.yaml diff --git a/deploy/service.yaml b/deploy/base/service.yaml similarity index 100% rename from deploy/service.yaml rename to deploy/base/service.yaml diff --git a/deploy/configmap.yaml b/deploy/configmap.yaml deleted file mode 100644 index 7f9ec0c..0000000 --- a/deploy/configmap.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: wynnsource-config -data: - LEVEL: "DEBUG" - MAX_IP_RECORDS: "10" diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml deleted file mode 100644 index 1a8eebe..0000000 --- a/deploy/kustomization.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: wynnsource-dev - # - configmap.yaml -resources: -- postgres.yaml -- redis.yaml -- deployment.yaml -- service.yaml -- migration-job.yaml -images: -- name: ghcr.io/wynnsource/wynnsource-server - newName: ghcr.io/wynnsource/wynnsource-server - newTag: 24e0b31d4dcd0b1ac8d984fe741584556a6b8703 diff --git a/deploy/overlays/dev/kustomization.yaml b/deploy/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..67a22ab --- /dev/null +++ b/deploy/overlays/dev/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: wynnsource-dev +resources: + - ../../base +images: + - name: ghcr.io/wynnsource/wynnsource-server + newTag: dev-latest diff --git a/deploy/overlays/prod/kustomization.yaml b/deploy/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..6602c1a --- /dev/null +++ b/deploy/overlays/prod/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: wynnsource +resources: + - ../../base +images: + - name: ghcr.io/wynnsource/wynnsource-server + newTag: latest +patches: + - target: + kind: Deployment + name: wynnsource-server + patch: | + - op: replace + path: /spec/replicas + value: 2 diff --git a/migration/versions/8129be0ee115_added_raid_gambits.py b/migration/versions/8129be0ee115_added_raid_gambits.py new file mode 100644 index 0000000..b108991 --- /dev/null +++ b/migration/versions/8129be0ee115_added_raid_gambits.py @@ -0,0 +1,63 @@ +"""added raid gambits + +Revision ID: 8129be0ee115 +Revises: b6251978ce59 +Create Date: 2026-04-08 17:51:11.189952 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8129be0ee115' +down_revision: Union[str, Sequence[str], None] = 'b6251978ce59' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('gambits', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('region', sa.String(length=50), nullable=False), + sa.Column('rotation_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('rotation_end', sa.DateTime(timezone=True), nullable=False), + sa.Column('consensus_data', sa.ARRAY(sa.String(length=255)), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('confidence', sa.Float(), nullable=False), + sa.Column('needs_recalc', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('region', 'rotation_start', name='uq_gambit_key') + ) + op.create_table('gambit_submissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('gambit_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('submitted_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('client_timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('mod_version', sa.String(length=50), nullable=False), + sa.Column('gambit_names', sa.ARRAY(sa.String(length=255)), nullable=False), + sa.Column('gambit_descriptions', sa.ARRAY(sa.String(length=255)), nullable=False), + sa.Column('weight', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['gambit_id'], ['gambits.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_gambit_submissions_gambit_id'), 'gambit_submissions', ['gambit_id'], unique=False) + op.create_index(op.f('ix_gambit_submissions_user_id'), 'gambit_submissions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_gambit_submissions_user_id'), table_name='gambit_submissions') + op.drop_index(op.f('ix_gambit_submissions_gambit_id'), table_name='gambit_submissions') + op.drop_table('gambit_submissions') + op.drop_table('gambits') + # ### end Alembic commands ### diff --git a/schema b/schema index a55477a..b482b54 160000 --- a/schema +++ b/schema @@ -1 +1 @@ -Subproject commit a55477a18aee758d4a289795e84b9961d9c141ad +Subproject commit b482b54955b5023036c71566c51cf0f4140783c7 diff --git a/test_pydantic.py b/test_pydantic.py deleted file mode 100644 index 062727d..0000000 --- a/test_pydantic.py +++ /dev/null @@ -1,44 +0,0 @@ -from pydantic_settings import BaseSettings -from pydantic import computed_field, PostgresDsn, RedisDsn - -class DbConfig(BaseSettings): - postgres_host: str = "localhost" - postgres_port: int = 5432 - postgres_user: str = "user" - postgres_password: str = "password" - postgres_db: str = "wcs_db" - - redis_host: str = "localhost" - redis_port: int = 6379 - redis_password: str | None = None - redis_db: str = "0" - redis_enabled: bool = True - - @computed_field - @property - def postgres_dsn(self) -> PostgresDsn: - return PostgresDsn.build( - scheme="postgresql+asyncpg", - username=self.postgres_user, - password=self.postgres_password, - host=self.postgres_host, - port=self.postgres_port, - path=self.postgres_db, - ) - - @computed_field - @property - def redis_dsn(self) -> RedisDsn | None: - if not self.redis_enabled: - return None - return RedisDsn.build( - scheme="redis", - password=self.redis_password, - host=self.redis_host, - port=self.redis_port, - path=self.redis_db, - ) - -cfg = DbConfig() -print(cfg.postgres_dsn) -print(cfg.redis_dsn) From 6989578abd192d2fd67d3a489db955f06f2faf29 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 8 Apr 2026 19:28:33 -0400 Subject: [PATCH 76/78] fix: update ruff check and format commands to target 'app' directory --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4e0aa7..d3a3242 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,8 +17,8 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 - run: uv sync --locked - - run: uv run ruff check . - - run: uv run ruff format --check . + - run: uv run ruff check app + - run: uv run ruff format --check app build-and-push: needs: lint From 70fcdd083fb046777ed2561476c5ed9403ba7459 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 8 Apr 2026 19:29:45 -0400 Subject: [PATCH 77/78] fix: refine score delta calculation and improve logging format --- app/core/score.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/core/score.py b/app/core/score.py index b98530e..9190def 100644 --- a/app/core/score.py +++ b/app/core/score.py @@ -150,15 +150,14 @@ async def update_user_scores(): tier = Tier.get_by_score(user.score) delta = tier.daily_base * (quality * QUALITY_WEIGHT + activity * ACTIVITY_WEIGHT) - delta = int(round(delta)) + delta = round(delta) delta = max(-tier.daily_base, min(tier.daily_base, delta)) new_score = max(SCORE_MIN, min(SCORE_MAX, user.score + delta)) user.score = new_score LOGGER.debug( - f"User {user.id}: quality={quality:.3f}, activity={activity:.3f}, " - f"delta={delta}, new_score={new_score}" + f"User {user.id}: quality={quality:.3f}, activity={activity:.3f}, delta={delta}, new_score={new_score}" ) LOGGER.info(f"Updated scores for {len(users)} users") @@ -207,9 +206,7 @@ def calculate_activity_factor(user_id: int, pools: list["Pool"]) -> float: if total_pools == 0: return 0.0 - user_pools = sum( - 1 for pool in pools if any(sub.user_id == user_id for sub in pool.submissions) - ) + user_pools = sum(1 for pool in pools if any(sub.user_id == user_id for sub in pool.submissions)) ratio = user_pools / total_pools return min(1.0, ratio / ACTIVITY_THRESHOLD) From c15b66888fd4a523e6698917d79d97321330e4b4 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Wed, 8 Apr 2026 19:31:24 -0400 Subject: [PATCH 78/78] fix: remove ruff format check from CI workflow --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d3a3242..c36be7c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,6 @@ jobs: - uses: astral-sh/setup-uv@v6 - run: uv sync --locked - run: uv run ruff check app - - run: uv run ruff format --check app build-and-push: needs: lint