From 20ac4b7f0ad5a495ced3e1ee340667d96b25aa55 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Fri, 13 Mar 2026 15:18:54 -0400 Subject: [PATCH 01/21] Reworked entire service to FastAPI, uv, dependency updates, Makefile, Dockerfile, tests, the entire works --- .dockerignore | 17 + .flaskenv | 7 - .github/workflows/codacy-analysis.yml | 42 +- .github/workflows/codeql-analysis.yml | 76 +- .gitignore | 5 + Dockerfile | 51 + Makefile | 97 ++ README.md | 75 +- config/default.example.cfg | 13 +- lgtm.yml | 149 --- main.py | 16 +- pyproject.toml | 36 + pyvenv.cfg | 3 - requirements.txt | 37 - service/__init__.py | 85 +- service/config.py | 28 + service/models.py | 52 + service/routes/__init__.py | 13 +- service/routes/dc.py | 277 +++++- service/routes/healthcheck.py | 18 +- service/routes/marvel.py | 272 +++++- service/routes/swagger.py | 7 +- service/settings.py | 6 - service/utils/api.py | 95 +- service/utils/common.py | 17 +- service/utils/error.py | 26 +- service/utils/file.py | 84 +- setup.cfg | 7 - setup.py | 10 - MANIFEST.in => tests/.this_is_a_git_submodule | 0 tests/README.md | 9 + tests/test_factory.py | 25 - {tests => unit}/conftest.py | 15 +- {tests => unit}/test_api.py | 6 +- {tests => unit}/test_common.py | 0 {tests => unit}/test_dc.py | 39 +- {tests => unit}/test_error.py | 0 unit/test_factory.py | 31 + {tests => unit}/test_file.py | 0 {tests => unit}/test_marvel.py | 39 +- uv.lock | 895 ++++++++++++++++++ 41 files changed, 2021 insertions(+), 659 deletions(-) create mode 100644 .dockerignore delete mode 100644 .flaskenv create mode 100644 Dockerfile create mode 100644 Makefile delete mode 100644 lgtm.yml create mode 100644 pyproject.toml delete mode 100644 pyvenv.cfg delete mode 100644 requirements.txt create mode 100644 service/config.py create mode 100644 service/models.py delete mode 100644 service/settings.py delete mode 100644 setup.cfg delete mode 100644 setup.py rename MANIFEST.in => tests/.this_is_a_git_submodule (100%) create mode 100644 tests/README.md delete mode 100644 tests/test_factory.py rename {tests => unit}/conftest.py (96%) rename {tests => unit}/test_api.py (97%) rename {tests => unit}/test_common.py (100%) rename {tests => unit}/test_dc.py (66%) rename {tests => unit}/test_error.py (100%) create mode 100644 unit/test_factory.py rename {tests => unit}/test_file.py (100%) rename {tests => unit}/test_marvel.py (66%) create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a382cb7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.venv +*.pem +htmlcov +coverage +.github +.circleci +img +unit +tests +config/.env +__pycache__ +*.pyc +*.egg-info +dist +build +simple-super-hero-service-upgrade.md diff --git a/.flaskenv b/.flaskenv deleted file mode 100644 index 73f6aec..0000000 --- a/.flaskenv +++ /dev/null @@ -1,7 +0,0 @@ -export FLASK_APP=main.py -export FLASK_ENV=development -export FLASK_DEBUG=True -export FLASK_RUN_HOST=127.0.0.1 -export FLASK_RUN_PORT=5000 -export FLASK_RUN_CERT=sssp-cert.pem -export FLASK_RUN_KEY=sssp-key.pem diff --git a/.github/workflows/codacy-analysis.yml b/.github/workflows/codacy-analysis.yml index c9e3e9c..dde4707 100644 --- a/.github/workflows/codacy-analysis.yml +++ b/.github/workflows/codacy-analysis.yml @@ -13,19 +13,35 @@ on: branches: [ "master", "main" ] pull_request: branches: [ "master", "main" ] + workflow_dispatch: jobs: codacy-security-scan: name: Codacy Security Scan runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: # Checkout the repository to the GitHub Actions runner - name: Checkout code - uses: actions/checkout@v2 - + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.GITHUB_TOKEN }} + + # Generate temporary self-signed TLS certificates required by the service + - name: Generate temporary TLS certificates + run: | + openssl req -x509 -newkey rsa:4096 -keyout sss-key.pem -out sss-cert.pem \ + -sha256 -days 1 -nodes \ + -subj "/C=US/ST=CI/L=CI/O=CI/CN=localhost" + # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - name: Run Codacy Analysis CLI - uses: codacy/codacy-analysis-cli-action@1.0.0 + uses: codacy/codacy-analysis-cli-action@v4 with: # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository # You can also omit the token and run the tools that support default configurations @@ -37,8 +53,24 @@ jobs: # This will handover control about PR rejection to the GitHub side max-allowed-issues: 2147483647 + # Split the multi-run SARIF into individual per-tool files to satisfy the + # requirement that each category upload contains only a single run. + # See: https://github.blog/changelog/2025-07-21-code-scanning-will-stop-combining-multiple-sarif-runs-uploaded-in-the-same-sarif-file/ + - name: Split SARIF results into individual tool files + run: | + mkdir -p sarif-runs + count=$(jq '.runs | length' results.sarif) + for i in $(seq 0 $((count - 1))); do + tool=$(jq -r ".runs[$i].tool.driver.name" results.sarif \ + | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g') + jq --argjson i "$i" \ + '{"version": .version, "$schema": .["$schema"], "runs": [.runs[$i]]}' \ + results.sarif > "sarif-runs/${tool}.sarif" + done + # Upload the SARIF file generated in the previous step - name: Upload SARIF results file - uses: github/codeql-action/upload-sarif@v1 + uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: results.sarif + sarif_file: sarif-runs/ + category: codacy diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2782380..20bc3c1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -7,10 +7,9 @@ name: "CodeQL" on: push: - branches: [master] + branches: [ "master", "main" ] pull_request: - # The branches below must be a subset of the branches above - branches: [master] + branches: [ "master", "main" ] schedule: - cron: '0 20 * * 0' @@ -18,54 +17,35 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write strategy: fail-fast: false matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['python'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + # Supported options: https://aka.ms/codeql-docs/language-support + language: [ 'python' ] steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Python is an interpreted language so Autobuild is a no-op, but keeping + # it here means the workflow works if compiled languages are added later. + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore index 28f5f3b..256d864 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ venv/ +.venv/ .idea/ +# Environment / secrets +.env +config/.env + *.pyc __pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ee716a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# ARG before FROM parameterises the base image tag; override with --build-arg PYTHON=3.13 +ARG PYTHON=3.12 + +FROM python:${PYTHON}-slim +LABEL maintainer="MORGANGRAPHICS,INC" + +ARG PORT=8000 + +# Install curl (HEALTHCHECK) and dumb-init (PID 1 / signal forwarding). +# Clean up apt cache so it is not stored in the layer. +RUN apt-get update -y \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends curl dumb-init \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for fast, reproducible dependency installation. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# The official python image ships with a non-root user we create explicitly. +RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser + +USER appuser + +ENV PORT=${PORT} +# Keep Python from writing .pyc files and buffering stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# Tell uv to install into the system Python inside the container +ENV UV_SYSTEM_PYTHON=1 + +WORKDIR /home/appuser/service + +# Copy dependency manifests first so the install layer is only invalidated +# when dependencies change, not on every source file change. +COPY --chown=appuser:appuser pyproject.toml uv.lock* ./ + +RUN uv sync --no-dev --frozen + +# NOTE: sssp-cert.pem and sssp-key.pem are excluded via .dockerignore and must +# be mounted at runtime, e.g.: +# docker run -v /path/to/certs:/home/appuser/service ... +COPY --chown=appuser:appuser . . + +EXPOSE ${PORT} + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsk https://localhost:${PORT}/healthcheck || exit 1 + +# https://github.com/Yelp/dumb-init#usage +ENTRYPOINT ["/usr/bin/dumb-init", "--"] +CMD ["python", "main.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ebe27ac --- /dev/null +++ b/Makefile @@ -0,0 +1,97 @@ +.DEFAULT_GOAL := help + +# ─── Virtual environment ────────────────────────────────────────────────────── + +.venv: + uv venv .venv --python 3.12 + +install: .venv ## Install all dependencies (including dev) + uv sync --extra dev + +# ─── Service ────────────────────────────────────────────────────────────────── + +start: ## Start the service (foreground) + uv run python main.py + +dev: ## Start the service with auto-reload + uv run uvicorn service:create_app --factory --reload --host 127.0.0.1 --port 8000 + +stop: ## Stop any background uvicorn process + @pkill -f "uvicorn service:create_app" && echo "Service stopped." || echo "No running service found." + +# ─── Tests ──────────────────────────────────────────────────────────────────── + +test: ## Run all unit tests + uv run pytest unit/ + +test-unit: ## Run unit tests (alias) + uv run pytest unit/ + +test-file: ## Run file utility tests only + uv run pytest unit/test_file.py -s + +test-verbose: ## Run unit tests with verbose output + uv run pytest unit/ -v + +# ─── Coverage ───────────────────────────────────────────────────────────────── + +coverage: ## Run tests with coverage report (terminal) + uv run coverage erase + uv run coverage run -m pytest unit/ + uv run coverage report --show-missing + +coverage-html: ## Run tests with coverage report (HTML) + uv run coverage erase + uv run coverage run -m pytest unit/ + uv run coverage html + @echo "Report available at htmlcov/index.html" + +# ─── Code quality ───────────────────────────────────────────────────────────── + +lint: ## Run ruff linter + uv run ruff check . + +lint-fix: ## Run ruff linter and auto-fix + uv run ruff check . --fix + +format: ## Run black formatter + uv run black . + +format-check: ## Check formatting without making changes + uv run black . --check + +# ─── SSL certs ──────────────────────────────────────────────────────────────── + +certs: ## Generate self-signed SSL certs for local development + openssl req -x509 -newkey rsa:4096 -keyout sssp-key.pem -out sssp-cert.pem \ + -days 365 -nodes -subj "/CN=localhost" + +# ─── Docker ─────────────────────────────────────────────────────────────────── + +IMAGE ?= simple-superhero-service-python + +docker-build: ## Build the Docker image + docker build -t $(IMAGE) . + +docker-start: ## Run the container (mounts local certs, exposes port 8000) + docker run --rm -p 8000:8000 \ + -v $(PWD)/sssp-cert.pem:/home/appuser/service/sssp-cert.pem:ro \ + -v $(PWD)/sssp-key.pem:/home/appuser/service/sssp-key.pem:ro \ + -v $(PWD)/config/.env:/home/appuser/service/config/.env:ro \ + $(IMAGE) + +docker-stop: ## Stop the running container + @docker stop $$(docker ps -q --filter ancestor=$(IMAGE)) 2>/dev/null && echo "Container stopped." || echo "No running container found." + +docker-shell: ## Open a shell in a new container + docker run --rm -it $(IMAGE) /bin/bash + +# ─── Help ───────────────────────────────────────────────────────────────────── + +help: ## Show this help message + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: install start dev stop test test-unit test-file test-verbose \ + coverage coverage-html lint lint-fix format format-check certs \ + docker-build docker-start docker-stop docker-shell help diff --git a/README.md b/README.md index 85edc12..61540ab 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ I needed a self-contained, data service (no Database) for testing a number of different scenarios with a diverse and robust dataset that also contains some sparseness. -Service runs on Python and Flask +Service runs on Python and FastAPI The service itself and the data contained within service may be useful for testing: @@ -56,25 +56,56 @@ The service itself and the data contained within service may be useful for testi #### Requirements -Python 3.6+ -Flask 1.1+ +Python 3.12+ +uv 0.4+ #### Installation 1. Clone the repo `git clone https://github.com/morgangraphics/simple-superhero-service-python.git` -1. cd into the directory and install the requirements `pip install -r requirements.txt` or `pip3 install -r requirements.txt` +1. cd into the directory and create a virtual environment and install dependencies: + ```bash + uv venv .venv --python 3.12 + uv sync --extra dev + ``` 1. Generate a self signed cert `openssl req -new -newkey rsa:4096 -x509 -sha256 -days 365 -nodes -out sssp-cert.pem -keyout sssp-key.pem -days 365` -1. Rename the `config/default.example.cfg` file to `config/default.cfg` and update the ```` according to your setup (see below)\* -1. OR Update the `.flaskenv` file according to your setup if you plan to use the Flask CLI -1. You can run service via the Flask CLI `export FLASK_APP=main.py && flask run` or in a venv `./simple-superhero-service-python/venv/bin/flask run` or with plain ole python `export FLASK_APP=main.py && python -m flask run` + - Or use `make certs` (see below) +1. Copy `config/default.example.cfg` to `config/.env` and update the `` according to your setup (see below) +1. Run the service: `make start` 1. The self-signed certs will make the browser throw a `Potential Security Risk` error. Select the Advanced button/link and `Accept the risk and continue` button/link -Marvel URL: [https://localhost:5000/marvel](https://localhost:5000/marvel) +Marvel URL: [https://localhost:8000/marvel](https://localhost:8000/marvel) -DC URL: [https://localhost:5000/dc](https://localhost:5000/dc) +DC URL: [https://localhost:8000/dc](https://localhost:8000/dc) -Swagger Interface: [https://localhost:5000/swagger/#](https://localhost:5000/swagger/#) +Swagger Interface: [https://localhost:8000/swagger](https://localhost:8000/swagger) + +* :warning: SSL cert and key paths are configured via the `SSL_CERT` and `SSL_KEY` variables in `config/.env` + +#### Make commands + +Run `make help` to see all available commands. Full list (alphabetical): + +| Command | Description | +|----------------------|-----------------------------------------------------------| +| `make certs` | Generate self-signed SSL certs for local development | +| `make coverage` | Run tests with coverage report (terminal) | +| `make coverage-html` | Run tests with coverage report (HTML) | +| `make dev` | Start the service with auto-reload | +| `make docker-build` | Build the Docker image | +| `make docker-shell` | Open a shell in a new container | +| `make docker-start` | Run the container (mounts local certs, exposes port 8000) | +| `make docker-stop` | Stop the running container | +| `make format` | Run black formatter | +| `make format-check` | Check formatting without making changes | +| `make install` | Install all dependencies (including dev) | +| `make lint` | Run ruff linter | +| `make lint-fix` | Run ruff linter and auto-fix | +| `make start` | Start the service (foreground) | +| `make stop` | Stop any background uvicorn process | +| `make test` | Run all unit tests | +| `make test-file` | Run file utility tests only | +| `make test-unit` | Run unit tests (alias) | +| `make test-verbose` | Run unit tests with verbose output | -* :warning: This ONLY applies to when running Flask like `export FLASK_APP=main.py && python -m flask run` Do the same for `development.example.cfg` and `productions.example.cfg` if you want to override environment specific variables ## Dataset @@ -189,7 +220,7 @@ The base endpoints allow for retrieving data and applying a series of filters to ##### Examples -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/dc?pretty&limit=3&s=name:asc'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/dc?pretty&limit=3&s=name:asc'` ```json [ { @@ -240,7 +271,7 @@ The base endpoints allow for retrieving data and applying a series of filters to ] ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/dc?h=name,appearances&pretty&limit=3&s=name:asc'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/dc?h=name,appearances&pretty&limit=3&s=name:asc'` ```json [ { @@ -258,7 +289,7 @@ The base endpoints allow for retrieving data and applying a series of filters to ] ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/spider+man,-woman/?pretty&s=name:asc'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/spider+man,-woman/?pretty&s=name:asc'` ```json [ { @@ -324,7 +355,7 @@ The base endpoints allow for retrieving data and applying a series of filters to ] ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/spider+man,-woman/?pretty&s=name:asc&prune'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/spider+man,-woman/?pretty&s=name:asc&prune'` ```json [ { @@ -377,7 +408,7 @@ The base endpoints allow for retrieving data and applying a series of filters to ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel?help'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel?help'` ```text format | format | json | Output format (currently only JSON) headers | h | all | Available Columns (page_id, name, urlslug, id, align, eye, hair, sex, gsm, alive, appearances, first appearance, year) @@ -411,14 +442,14 @@ sort | s | unsorted | Sort response asc|desc e.g. s=name,appearances † Does not apply when sorting on column/header which contains a null value, records with null values are removed ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/dc?limit=2&random&seed'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/dc?limit=2&random&seed'` ```json [{"page_id":127398,"name":"cassandra cartland (new earth)","urlslug":"/wiki/cassandra_cartland_(new_earth)","id":"","align":"bad characters","eye":"green eyes","hair":"brown hair","sex":"female characters","gsm":"","alive":"living characters","appearances":6,"first appearance":"1997, february","year":1997},{"page_id":192282,"name":"poltergeist (new earth)","urlslug":"/wiki/poltergeist_(new_earth)","id":"","align":"bad characters","eye":"blue eyes","hair":"black hair","sex":"male characters","gsm":"","alive":"living characters","appearances":1,"first appearance":"1996, december","year":1996}] ``` In many cases where there are multiple characters with the same name (in alternative story lines) the first result is usually the "Original" character. That is how the data is structured. However, you might want to specifically target a character or characters by using the search filters. -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/spider+man,-woman,-616?pretty'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/spider+man,-woman,-616?pretty'` This request is looking for a character name that contains both "spider" AND 'man' BUT NOT "woman" or '616' ```json @@ -526,7 +557,7 @@ OR "sort": "asc" } ] -}' 'https://localhost:5000/marvel' +}' 'https://localhost:8000/marvel' ` ```json @@ -553,7 +584,7 @@ OR Character names can have a bit of variation which can be a bit inconsistent between story lines. The official name for Spider-man contains a dash, however, other variants can be Spiderman, Spider man, in addition to Spider-man. The service attempts to use permutation to generate different spellings depending on how the name is entered. If you are unsure, leave a space e.g. `spider man` and the service will search for `spider-man, spiderman, and spider man` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/spider%20man/?pretty'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/spider%20man/?pretty'` ```json [ @@ -606,7 +637,7 @@ Character names can have a bit of variation which can be a bit inconsistent betw ``` Filters work the same as the base endpoint. (Excluding `random` and `seed`) -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/iron%20man/?pretty&s=year:desc'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/iron%20man/?pretty&s=year:desc'` ```json [ @@ -688,7 +719,7 @@ Filters work the same as the base endpoint. (Excluding `random` and `seed`) ] ``` -`curl -X GET --header 'Accept: application/json' 'https://localhost:5000/marvel/iron%20man/?pretty&s=year:desc&nulls=last'` +`curl -X GET --header 'Accept: application/json' 'https://localhost:8000/marvel/iron%20man/?pretty&s=year:desc&nulls=last'` ```json [ diff --git a/config/default.example.cfg b/config/default.example.cfg index ac9145f..2557692 100644 --- a/config/default.example.cfg +++ b/config/default.example.cfg @@ -1,13 +1,12 @@ # ENVIRONMENT DEBUG=True -ENV="development" # SERVER -HOST="" -PORT= -SSL_CERT="sssp-cert.pem" -SSL_KEY="sssp-key.pem" -SECRET_KEY="" +HOST=127.0.0.1 +PORT=8000 +SSL_CERT=sssp-cert.pem +SSL_KEY=sssp-key.pem +SECRET_KEY= # CUSTOM -TEST_STRING="DEFAULT HELLO" +TEST_STRING=DEFAULT HELLO diff --git a/lgtm.yml b/lgtm.yml deleted file mode 100644 index f1379a3..0000000 --- a/lgtm.yml +++ /dev/null @@ -1,149 +0,0 @@ -########################################################################################## -# Customize file classifications. # -# Results from files under any classifier will be excluded from LGTM # -# statistics. # -########################################################################################## - -########################################################################################## -# Use the `path_classifiers` block to define changes to the default classification of # -# files. # -########################################################################################## - -path_classifiers: - # docs: - # Identify the top-level file called `generate_javadoc.py` as documentation-related. - # - generate_javadoc.py - test: - # Override LGTM's default classification of test files by excluding all files. - # - exclude: / - # Classify all files in the top-level directories test/ and testsuites/ as test code. - - tests - # - testsuites - # Classify all files with suffix `.test` as test code. - # Note: use only forward slash / as a path separator. - # Use ** to indicate an arbitrary parent path. - # Use * to indicate any sequence of characters excluding /. - # Always enclose the expression in double quotes if it includes *. - # - "**/*.test" - # Refine the classifications above by excluding files in test/util/. - # - exclude: test/util - # The default behavior is to tag all files created during the - # build as `generated`. Results are hidden for generated code. You can tag - # further files as being generated by adding them to the `generated` section. - # generated: - # Exclude all `*.c` files under the `ui/` directory from classification as - # generated code. - # - exclude: ui/**/*.c - # By default, all files not checked into the repository are considered to be - # 'generated'. - # The default behavior is to tag library code as `library`. Results are hidden - # for library code. You can tag further files as being library code by adding them - # to the `library` section. - # library: - # - exclude: path/to/libary/code/**/*.c - # The default behavior is to tag template files as `template`. Results are hidden - # for template files. You can tag further files as being template files by adding - # them to the `template` section. - # template: - # - exclude: path/to/template/code/**/*.c - # Define your own category, for example: 'some_custom_category'. - # some_custom_category: - # Classify all files in the top-level directory tools/ (or the top-level file - # called tools). - # - tools - -######################################################################################### -# Use the `queries` block to change the default display of query results. # -######################################################################################### - -queries: - # Start by hiding the results of all queries. - - include: "*" - - # Specifically hide the results of two queries. - - exclude: py/similar-function - -######################################################################################### -# Define changes to the default code extraction process. # -# Each block configures the extraction of a single language, and modifies actions in a # -# named step. Every named step includes automatic default actions, # -# except for the 'prepare' step. The steps are performed in the following sequence: # -# prepare # -# after_prepare # -# configure (C/C++ only) # -# python_setup (Python only) # -# before_index # -# index # -########################################################################################## - -######################################################################################### -# Environment variables available to the steps: # -######################################################################################### - -# LGTM_SRC -# The root of the source tree. -# LGTM_WORKSPACE -# An existing (initially empty) folder outside the source tree. -# Used for temporary download and setup commands. - -######################################################################################### -# Use the extraction block to define changes to the default code extraction process # -# for one or more languages. The settings for each language are defined in a child # -# block, with one or more steps. # -######################################################################################### - -extraction: - - # Define settings for Python analysis - ###################################### - python: - # The `prepare` step exists for customization on LGTM.com only. - # prepare: - # # The `packages` section is valid for LGTM.com only. It names packages to - # # be installed. - # packages: libpng-dev - # This step is useful for Python analysis where you want to prepare the - # environment for the `python_setup` step without changing the default behavior - # for that step. - after_prepare: - - export PATH=$LGTM_WORKSPACE/tools:$PATH - # This sets up the Python interpreter and virtual environment, ready for the - # `index` step to extract the codebase. - python_setup: - # Specify packages that should NOT be installed despite being mentioned in the - # requirements.txt file. - # Default: no package marked for exclusion. - # exclude_requirements: - # - pywin32 - # Specify a list of pip packages to install. - # If any of these packages cannot be installed, the extraction will fail. - # requirements: - # - Pillow - # Specify a list of requirements text files to use to set up the environment, - # or false for none. Default: any requirements.txt, test-requirements.txt, - # and similarly named files identified in the codebase are used. - requirements_files: - - requirements.txt - # Specify a setup.py file to use to set up the environment, or false for none. - # Default: any setup.py files identified in the codebase are used in preference - # to any requirements text files. - setup_py: setup.py - # Override the version of the Python interpreter used for setup and extraction - # Default: Python 3. - version: 3 - # Optional step. You should add a `before_index` step if you need to run commands - # before the `index` step. - # before_index: - # - antlr4 -Dlanguage=Python3 Grammar.g4 - # The `index` step extracts information from the files in the codebase. - # index: - # # Specify a list of files and folders to exclude from extraction. - # # Default: Git submodules and Subversion externals. - # exclude: - # - legacy-implementation - # - thirdparty/libs - # filters: - # - exclude: "**/documentation/examples/snippets/*.py" - # - include: "**/documentation/examples/test_application/*" - # include: - # - example/to/include diff --git a/main.py b/main.py index 8317138..fee0e40 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,16 @@ +import uvicorn + from service import create_app +from service.config import get_settings app = create_app() -# Stub that allows for running from commandline via python main.py if __name__ == "__main__": - # Setting debug to True enables debug output. This line should be - app.run( - host=app.config.get("HOST"), - port=app.config.get("PORT"), - ssl_context=(app.config.get("SSL_CERT"), app.config.get("SSL_KEY")), + settings = get_settings() + uvicorn.run( + app, + host=settings.host, + port=settings.port, + ssl_certfile=settings.ssl_cert, + ssl_keyfile=settings.ssl_key, ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..74b6512 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "simple-superhero-service" +version = "2.0.0" +description = "A self-contained superhero data service built with FastAPI" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "httpx>=0.28.0", + "anyio[trio]>=4.7.0", + "coverage>=7.6.0", + "black>=24.10.0", + "ruff>=0.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["service"] + +[tool.pytest.ini_options] +testpaths = ["unit"] + +[tool.coverage.run] +branch = true +source = ["service"] diff --git a/pyvenv.cfg b/pyvenv.cfg deleted file mode 100644 index 20ac621..0000000 --- a/pyvenv.cfg +++ /dev/null @@ -1,3 +0,0 @@ -home = /usr/bin -include-system-site-packages = false -version = 3.5.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3806470..0000000 --- a/requirements.txt +++ /dev/null @@ -1,37 +0,0 @@ -aniso8601==8.0.0 -appdirs==1.4.4 -attrs==19.3.0 -black==19.10b0 -cffi==1.14.0 -click==7.1.2 -confuse==1.3.0 -coverage==5.1 -cryptography==3.3.2 -csvmapper==0.7 -Flask==1.1.2 -flask-swagger-ui==3.25.0 -importlib-metadata==1.7.0 -itsdangerous==1.1.0 -Jinja2==2.11.2 -jsonschema==3.2.0 -MarkupSafe==1.1.1 -more-itertools==8.4.0 -packaging==20.4 -pathspec==0.8.0 -pluggy==0.13.1 -py==1.9.0 -pycparser==2.20 -pyOpenSSL==19.1.0 -pyparsing==2.4.7 -pyrsistent==0.16.0 -pytest==5.4.3 -python-dotenv==0.13.0 -pytz==2020.1 -PyYAML==5.3.1 -regex==2020.6.8 -six==1.15.0 -toml==0.10.1 -typed-ast==1.4.1 -wcwidth==0.2.5 -Werkzeug==1.0.1 -zipp==3.1.0 diff --git a/service/__init__.py b/service/__init__.py index 2ac5320..ccf2ef3 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -1,63 +1,52 @@ -import os -from flask import Flask -from flask import jsonify from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + from .utils import InvalidUsage -def create_app(test_config=None, **kwargs): - """ - Flask Service Entry Point/Initialization +_DESCRIPTION = """I needed a self-contained data service (no Database) for testing a number of different \ +scenarios with a diverse and robust dataset that also contains some sparseness. - :param test_config: - :return: - """ - # create and configure the app - app = Flask(__name__, instance_relative_config=False) +Service runs on Python and FastAPI. - @app.errorhandler(InvalidUsage) - def handle_invalid_usage(error): - """ - Error Handler +The service itself and the data contained within service is useful for testing: - :param error: - :return: - """ - response = jsonify(error.to_dict()) - response.status_code = error.status_code - return response +1. CORS configuration +1. Server configuration +1. Bandwidth +1. Form population +1. Data visualization +1. Stubbing out UI components +... - default_yaml_path = ( - Path(__file__).resolve().parent.parent / "config" / "default.cfg" - ) - environment_yaml_path = "" - environment = ( - kwargs.get("environment") - if kwargs.get("environment") - else os.environ.get("FLASK_ENV") - ) - path = Path(__file__).resolve().parent.parent / "config" / f"{environment}.cfg" +Data is the comic book character dataset from \ +[fivethrityeight](https://datahub.io/five-thirty-eight/comic-characters#readme)""" - if path.is_file(): - environment_yaml_path = path - if test_config is None: - # Will load Environment Variables from .env file if needed and exists - app.config.from_pyfile("settings.py", silent=False) - # Will load default application variables - app.config.from_pyfile(default_yaml_path, silent=False) - # Will override default application variables depending on environment - app.config.from_pyfile(environment_yaml_path, silent=True) +def create_app() -> FastAPI: + """ + FastAPI application factory. + """ + app = FastAPI( + title="Simple Superhero Service API Documentation", + version="2.0.0", + description=_DESCRIPTION, + contact={"name": "MORGANGRAPHICS", "url": "https://github.com/morgangraphics"}, + docs_url="/swagger", + redoc_url=None, + openapi_url="/openapi.json", + ) - else: - # load the test config if passed in - app.config.from_mapping(test_config) + # Mount the static directory for any static assets + static_path = Path(__file__).resolve().parent / "static" + app.mount("/static", StaticFiles(directory=str(static_path)), name="static") - # ensure the instance folder exists - try: - os.makedirs(app.instance_path) - except OSError: - pass + @app.exception_handler(InvalidUsage) + async def handle_invalid_usage(request: Request, error: InvalidUsage) -> JSONResponse: + return JSONResponse(status_code=error.status_code, content=error.to_dict()) from service import routes diff --git a/service/config.py b/service/config.py new file mode 100644 index 0000000..04e7b85 --- /dev/null +++ b/service/config.py @@ -0,0 +1,28 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(Path(__file__).resolve().parent.parent / "config" / ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + # Server + host: str = "127.0.0.1" + port: int = 8000 + debug: bool = False + ssl_cert: str | None = None + ssl_key: str | None = None + secret_key: str = "changeme" + + # Custom / tests + test_string: str = "DEFAULT HELLO" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/service/models.py b/service/models.py new file mode 100644 index 0000000..5ec7e88 --- /dev/null +++ b/service/models.py @@ -0,0 +1,52 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel): + status: str + + +class ErrorResponse(BaseModel): + message: str + + +class SortParam(BaseModel): + column: str + sort: bool = False + + +class CharacterRecord(BaseModel): + page_id: int | None = None + name: str | None = None + urlslug: str | None = None + id: str | None = None + align: str | None = None + eye: str | None = None + hair: str | None = None + sex: str | None = None + gsm: str | None = None + alive: str | None = None + appearances: int | None = None + first_appearance: str | None = None + year: int | None = None + + +_TF_TEXT = "No default value is required, presence equates to true" + + +class CharacterSearchBody(BaseModel): + """Request body for POST endpoints.""" + + characters: list[str] | str | None = Field(None, description="Character(s) to search for. Either a string or Array of strings.") + format: str | None = Field(None, description="Output format (currently only JSON)") + h: list[str] | str | None = Field(None, description="Headers to display. Either a string or Array of strings") + help: bool | None = Field(None, description=f"List available options. {_TF_TEXT}") + limit: int | None = Field(None, description="Limit result set. '0' for no limit") + nulls: str | None = Field(None, description=f"Sort null values first or last in order. {_TF_TEXT}") + pretty: bool | str | None = Field(None, description=f"Pretty print the result set. {_TF_TEXT}") + prune: bool | str | None = Field(None, description=f"Remove keys with null values. {_TF_TEXT}") + random: bool | str | None = Field(None, description=f"Returns array of random superheros based on limit. {_TF_TEXT}") + s: list[SortParam | dict[str, Any]] | str | None = Field(None, description="Columns to sort on. Either a string or Array of sort objects") + seed: bool | str | None = Field(None, description=f"Keep the same random characters on multiple requests. {_TF_TEXT}") + universe: str | None = None diff --git a/service/routes/__init__.py b/service/routes/__init__.py index 618ecd9..4550ff0 100644 --- a/service/routes/__init__.py +++ b/service/routes/__init__.py @@ -1,11 +1,12 @@ +from fastapi import FastAPI + from .dc import bp_dc from .healthcheck import bp_hc from .marvel import bp_marvel -from .swagger import bp_swagger, SWAGGER_URL -def init_app(app): - app.register_blueprint(bp_dc) - app.register_blueprint(bp_hc) - app.register_blueprint(bp_marvel) - app.register_blueprint(bp_swagger, url_prefix=SWAGGER_URL) +def init_app(app: FastAPI) -> None: + app.include_router(bp_dc) + app.include_router(bp_hc) + app.include_router(bp_marvel) + diff --git a/service/routes/dc.py b/service/routes/dc.py index 6069c9e..5718e82 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -2,61 +2,238 @@ DC Comic Book Character Routing Setup """ -from flask import Blueprint -from flask import json -from flask import request -from flask import Response -from markupsafe import escape -from ..utils import ApiUtils -from ..utils import ReadFile -from ..utils import InvalidUsage - -bp_dc = Blueprint("dc", __name__, url_prefix="/dc") - - -@bp_dc.route("/", methods=["GET", "POST"]) -@bp_dc.route("//", methods=["GET"]) -def dc(characters=None): - """ - DC endpoint. Flask defaults to trailing slash which works for - both dc/badman and /dc/batman/ - - :param characters: (str) String representation of DC characters to search/filter by e.g batman - """ - api = ApiUtils() - options = dict().copy() - options.update(request.args) +import json +import re +from typing import Annotated, Optional - if request.method == "GET" and characters is not None: - options.update({"characters": escape(characters)}) +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse, PlainTextResponse - if request.method == "POST": - options.update(request.json) +from ..models import CharacterSearchBody +from ..utils import ApiUtils, InvalidUsage, ReadFile - if not request.args.get("universe"): - options.update({"universe": "dc"}) +bp_dc = APIRouter(prefix="/dc") - config = api.handle_config(options) +_TF_TEXT = "No default value is required, presence equates to true" + +_BASE_DESCRIPTION = """Returns an array of JSON objects of DC Character Universe Biographical \ +Information as found from https://datahub.io/five-thirty-eight/comic-characters dataset + +Shorthand query syntax is available for help, pretty, prune, random and seed. \ +Meaning their presence equates to true + +e.g. `?pretty&random` and `?pretty=true&random=true` are functionally equivalent + +\\* Swagger parameter functionality below only allows for `?pretty=true|false` \ +formatting for "Try it out" button""" + +_CHAR_DESCRIPTION = """Returns an array of JSON objects of DC Character Biographical \ +Information as found from https://datahub.io/five-thirty-eight/comic-characters dataset + +Shorthand query syntax is available for help, pretty, and prune. \ +Meaning their presence equates to true + +e.g. `?pretty` and `?pretty=true` are functionally equivalent + +**character: character filters can used like:** + +`{keyword1},{keyword2}` e.g. superman,batman will search for each character individually + +`{keyword1}+{keyword2}` e.g. bat+man will search for a character name with both \ +'bat' AND 'man' in it + +`{keyword1},-{keyword2}` e.g. superman,-woman will search for character names \ +containing 'superman' EXCLUDING results with -woman in it""" + +_POST_DESCRIPTION = _CHAR_DESCRIPTION + """ + +--- + +**character: character can be a string, or an array of strings (preferred)** e.g. + +```json +{ "character": "superman,batman" } +``` +OR +```json +{ "character": ["superman", "batman"] } +``` + +**h: h can be a string, or an array (preferred)** e.g. + +```json +{ "h": "name,appearances,year" } +``` +OR +```json +{ "h": ["name", "appearances", "year"] } +``` + +**s: can be a string, an object, array of strings, or an array of objects (preferred)** e.g. - if request.args.get("help") or request.args.get("help") == "": - return Response(api.show_help(), mimetype="text/plain") +```json +{ "s": "name:asc,appearances:desc" } +``` +OR +```json +{ "s": { "column": "name", "sort": "asc" } } +``` +OR +```json +{ "s": ["name:asc", "appearances:desc"] } +``` +OR +```json +{ "s": [{ "column": "name", "sort": "asc" }, { "column": "appearances", "sort": "desc" }] } +```""" + +def _sanitize(value: str) -> str: + """Strip characters that are not URL/name safe from a path parameter.""" + return re.sub(r"[^\w\s.,+:@-]", "", value) + + +def _build_options( + characters: Optional[str], + format: Optional[str], + h: Optional[str], + help: Optional[str], + limit: Optional[str], + nulls: Optional[str], + pretty: Optional[str], + prune: Optional[str], + random: Optional[str], + s: Optional[str], + seed: Optional[str], + universe: str, +) -> dict: + options: dict = {"universe": universe} + if characters is not None: + options["characters"] = characters + for key, val in { + "format": format, + "h": h, + "limit": limit, + "nulls": nulls, + "s": s, + }.items(): + if val is not None: + options[key] = val + for key, val in { + "help": help, + "pretty": pretty, + "prune": prune, + "random": random, + "seed": seed, + }.items(): + if val is not None: + options[key] = val + return options + + +def _respond(api: ApiUtils, config: dict): + if config.get("help"): + return PlainTextResponse(api.show_help()) + + try: + data = ReadFile(config).get_data() + except (TypeError, InvalidUsage) as error: + raise InvalidUsage(error) + + if config.get("pretty"): + body = json.dumps(data, indent=4, separators=(",", ": "), sort_keys=False, ensure_ascii=False) else: + body = json.dumps(data, sort_keys=False, ensure_ascii=False) + + return JSONResponse(content=json.loads(body)) + + +@bp_dc.get( + "/", + tags=["dc"], + summary="Filterable response of DC Character Universe Biographical information", + description=_BASE_DESCRIPTION, +) +def dc_get_base( + characters: Annotated[Optional[str], Query(description="Character(s) to search for. Either a string or Array of strings.")] = None, + format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, + h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, + limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, + nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, + prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, + random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, + s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, + seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, + universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, +): + api = ApiUtils() + options = _build_options( + characters=characters, + format=format, + h=h, + help=help, + limit=limit, + nulls=nulls, + pretty=pretty, + prune=prune, + random=random, + s=s, + seed=seed, + universe=universe or "dc", + ) + config = api.handle_config(options) + return _respond(api, config) + + +@bp_dc.get( + "/{characters}", + tags=["dc"], + summary="Search for specific DC Universe Character(s)", + description=_CHAR_DESCRIPTION, +) +def dc_get_by_character( + characters: str, + format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, + h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, + limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, + nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, + prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, + s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, +): + api = ApiUtils() + safe_chars = _sanitize(characters) + options = _build_options( + characters=safe_chars, + format=format, + h=h, + help=help, + limit=limit, + nulls=nulls, + pretty=pretty, + prune=prune, + random=None, + s=s, + seed=None, + universe="dc", + ) + config = api.handle_config(options) + return _respond(api, config) + + +@bp_dc.post( + "/", + tags=["dc"], + summary="Search for specific DC Universe Character(s)", + description=_POST_DESCRIPTION, +) +def dc_post(body: CharacterSearchBody): + api = ApiUtils() + options = body.model_dump(exclude_none=True) + options.setdefault("universe", "dc") + config = api.handle_config(options) + return _respond(api, config) - try: - data = ReadFile(config).get_data() - except (TypeError, InvalidUsage) as error: - raise InvalidUsage(error) - - if config.get("pretty"): - response = json.dumps( - data, - indent=4, - separators=(",", ": "), - sort_keys=False, - ensure_ascii=False, - ).encode("utf-8") - else: - response = json.dumps(data, sort_keys=False, ensure_ascii=False) - - return Response(response, mimetype="application/json") diff --git a/service/routes/healthcheck.py b/service/routes/healthcheck.py index 7f337ad..be66e97 100644 --- a/service/routes/healthcheck.py +++ b/service/routes/healthcheck.py @@ -2,17 +2,15 @@ Simple Health check route """ -from flask import Blueprint -from flask import json +from fastapi import APIRouter +from ..models import HealthResponse -bp_hc = Blueprint("healthcheck", __name__) +bp_hc = APIRouter() -@bp_hc.route("/healthcheck", methods=["GET"]) -def healthcheck(): - """ - Simple health check endpoint - :return: - """ - return json.dumps({"status": "Ok"}) +@bp_hc.get("/healthcheck", tags=["healthcheck"], summary="Test if the Service is up", response_model=HealthResponse) +def healthcheck() -> HealthResponse: + """Simple health check endpoint.""" + return HealthResponse(status="Ok") + diff --git a/service/routes/marvel.py b/service/routes/marvel.py index f1f5dcc..0af0df4 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -1,62 +1,240 @@ """ -DC Comic Book Character Routing Setup +Marvel Comic Book Character Routing Setup """ -from flask import Blueprint -from flask import json -from flask import request -from flask import Response -from markupsafe import escape -from ..utils import ApiUtils -from ..utils import ReadFile -from ..utils import InvalidUsage +import json +import re +from typing import Annotated, Optional -bp_marvel = Blueprint("marvel", __name__, url_prefix="/marvel") +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse, PlainTextResponse +from ..models import CharacterSearchBody +from ..utils import ApiUtils, InvalidUsage, ReadFile -@bp_marvel.route("/", methods=["GET", "POST"]) -@bp_marvel.route("//", methods=["GET"]) -def marvel(characters=None): - """ - Marvel endpoint. Flask defaults to trailing slash which works for - both marvel/spider-man and /marvel/spider-man/ +bp_marvel = APIRouter(prefix="/marvel") - :param characters: (str) String representation of Marvel characters to search/filter by e.g spider-man - """ - api = ApiUtils() - options = dict().copy() - options.update(request.args) +_TF_TEXT = "No default value is required, presence equates to true" - if request.method == "GET" and characters is not None: - options.update({"characters": escape(characters)}) +_BASE_DESCRIPTION = """Returns an array of JSON objects of Marvel Character Universe Biographical \ +Information as found from https://datahub.io/five-thirty-eight/comic-characters dataset - if request.method == "POST": - options.update(request.json) +Shorthand query syntax is available for help, pretty, prune, random and seed. \ +Meaning their presence equates to true - if not request.args.get("universe"): - options.update({"universe": "marvel"}) +e.g. `?pretty&random` and `?pretty=true&random=true` are functionally equivalent + +\\* Swagger parameter functionality below only allows for `?pretty=true|false` \ +formatting for "Try it out" button""" + +_CHAR_DESCRIPTION = """Returns an array of JSON objects of Marvel Character Biographical \ +Information as found from https://datahub.io/five-thirty-eight/comic-characters dataset + +Shorthand query syntax is available for help, pretty, and prune. \ +Meaning their presence equates to true + +e.g. `?pretty` and `?pretty=true` are functionally equivalent + +**character: character filters can used like:** + +`{keyword1},{keyword2}` e.g. iron man,spider-man will search for each character individually + +`{keyword1}+{keyword2}` e.g. spider+man will search for a character name with both \ +'spider' AND 'man' in it + +`{keyword1},-{keyword2}` e.g. iron man,earth-616 will search for character names \ +containing 'iron man' EXCLUDING results with earth-616 in it""" + +_POST_DESCRIPTION = _CHAR_DESCRIPTION + """ + +--- + +**character: character can be a string, or an array of strings (preferred)** e.g. + +```json +{ "character": "spider-man,iron man" } +``` +OR +```json +{ "character": ["spider-man", "iron man"] } +``` + +**h: h can be a string, or an array (preferred)** e.g. + +```json +{ "h": "name,appearances,year" } +``` +OR +```json +{ "h": ["name", "appearances", "year"] } +``` + +**s: can be a string, an object, array of strings, or an array of objects (preferred)** e.g. + +```json +{ "s": "name:asc,appearances:desc" } +``` +OR +```json +{ "s": { "column": "name", "sort": "asc" } } +``` +OR +```json +{ "s": ["name:asc", "appearances:desc"] } +``` +OR +```json +{ "s": [{ "column": "name", "sort": "asc" }, { "column": "appearances", "sort": "desc" }] } +```""" - config = api.handle_config(options) - if request.args.get("help") or request.args.get("help") == "": - return Response(api.show_help(), mimetype="text/plain") +def _sanitize(value: str) -> str: + """Strip characters that are not URL/name safe from a path parameter.""" + return re.sub(r"[^\w\s.,+:@-]", "", value) + +def _build_options( + characters: Optional[str], + format: Optional[str], + h: Optional[str], + help: Optional[str], + limit: Optional[str], + nulls: Optional[str], + pretty: Optional[str], + prune: Optional[str], + random: Optional[str], + s: Optional[str], + seed: Optional[str], + universe: str, +) -> dict: + options: dict = {"universe": universe} + if characters is not None: + options["characters"] = characters + for key, val in { + "format": format, + "h": h, + "limit": limit, + "nulls": nulls, + "s": s, + }.items(): + if val is not None: + options[key] = val + # Presence-style flags: key present (even empty string) means True + for key, val in { + "help": help, + "pretty": pretty, + "prune": prune, + "random": random, + "seed": seed, + }.items(): + if val is not None: + options[key] = val + return options + + +def _respond(api: ApiUtils, config: dict): + if config.get("help"): + return PlainTextResponse(api.show_help()) + + try: + data = ReadFile(config).get_data() + except (TypeError, InvalidUsage) as error: + raise InvalidUsage(error) + + if config.get("pretty"): + body = json.dumps(data, indent=4, separators=(",", ": "), sort_keys=False, ensure_ascii=False) else: + body = json.dumps(data, sort_keys=False, ensure_ascii=False) + + return JSONResponse(content=json.loads(body)) + + +@bp_marvel.get( + "/", + tags=["marvel"], + summary="Filterable response of Marvel Character Universe Biographical information", + description=_BASE_DESCRIPTION, +) +def marvel_get_base( + characters: Annotated[Optional[str], Query(description="Character(s) to search for. Either a string or Array of strings.")] = None, + format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, + h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, + limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, + nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, + prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, + random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, + s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, + seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, + universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, +): + api = ApiUtils() + options = _build_options( + characters=characters, + format=format, + h=h, + help=help, + limit=limit, + nulls=nulls, + pretty=pretty, + prune=prune, + random=random, + s=s, + seed=seed, + universe=universe or "marvel", + ) + config = api.handle_config(options) + return _respond(api, config) + + +@bp_marvel.get( + "/{characters}", + tags=["marvel"], + summary="Search for specific Marvel Universe Character(s)", + description=_CHAR_DESCRIPTION, +) +def marvel_get_by_character( + characters: str, + format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, + h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, + limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, + nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, + prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, + s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, +): + api = ApiUtils() + safe_chars = _sanitize(characters) + options = _build_options( + characters=safe_chars, + format=format, + h=h, + help=help, + limit=limit, + nulls=nulls, + pretty=pretty, + prune=prune, + random=None, + s=s, + seed=None, + universe="marvel", + ) + config = api.handle_config(options) + return _respond(api, config) + + +@bp_marvel.post( + "/", + tags=["marvel"], + summary="Search for specific Marvel Universe Character(s)", + description=_POST_DESCRIPTION, +) +def marvel_post(body: CharacterSearchBody): + api = ApiUtils() + options = body.model_dump(exclude_none=True) + options.setdefault("universe", "marvel") + config = api.handle_config(options) + return _respond(api, config) - try: - data = ReadFile(config).get_data() - except (TypeError, InvalidUsage) as error: - raise InvalidUsage(error) - - if config.get("pretty"): - response = json.dumps( - data, - indent=4, - separators=(",", ": "), - sort_keys=False, - ensure_ascii=False, - ).encode("utf-8") - else: - response = json.dumps(data, sort_keys=False, ensure_ascii=False) - - return Response(response, mimetype="application/json") diff --git a/service/routes/swagger.py b/service/routes/swagger.py index a8408a7..a40fe14 100644 --- a/service/routes/swagger.py +++ b/service/routes/swagger.py @@ -1,6 +1,3 @@ -from flask_swagger_ui import get_swaggerui_blueprint +# Swagger UI is served by FastAPI built-in at /swagger (docs_url). +# The /static/swagger.json file is mounted via StaticFiles in service/__init__.py. -SWAGGER_URL = "/swagger" -API_URL = "/static/swagger.json" - -bp_swagger = get_swaggerui_blueprint(SWAGGER_URL, API_URL) diff --git a/service/settings.py b/service/settings.py deleted file mode 100644 index 2e2f1ea..0000000 --- a/service/settings.py +++ /dev/null @@ -1,6 +0,0 @@ -from dotenv import load_dotenv -from pathlib import Path - -# Load .env file -env_path = Path(__file__).resolve().parent.parent / "config" / ".env" -load_dotenv(dotenv_path=env_path.name) diff --git a/service/utils/api.py b/service/utils/api.py index 663b990..e27bad4 100644 --- a/service/utils/api.py +++ b/service/utils/api.py @@ -3,14 +3,11 @@ class ApiUtils(ServiceUtils): """ - ApiUtils class. - Contains: - Preformatted documentation - Default configuration - Sorting + ApiUtils + Pre-formatted documentation, default configuration, and sorting helpers. """ - def __init__(self): - """Inheritance here is probably not needed but done for testing out the idea""" + + def __init__(self) -> None: super().__init__() self.cols = """ @@ -66,11 +63,10 @@ def __init__(self): † Does not apply when sorting on column/header which contains a null value, records with null values are removed """ - def help_search(self, universe): + def help_search(self, universe: str) -> str: """ - Returns Universe specific examples/documentation based on param - :param universe: (str) Marvel/DC - :return: + Returns universe-specific examples/documentation. + :param universe: "marvel" or "dc" """ return f""" character | | empty | Output format (currently only JSON) @@ -80,63 +76,49 @@ def help_search(self, universe): | | |{self.help_base} """ - def handle_config(self, args): + def handle_config(self, args: dict) -> dict: """ - Normalize configuration dict used for retrieving data - :param args: (dict) of arguments passed in via GET Querystring, or POST JSON - :return: (dict) Normalized configuration dictionary + Normalise the configuration dict used for retrieving data. + :param args: Query-string or POST-body parameters. + :return: Normalised configuration dictionary. """ - config = dict() present = [True, "true", ""] + config: dict = {"format": "json"} if args.get("characters") is not None: - config["characters"] = self.character_search_dict(args.get("characters")) - - config["format"] = "json" + config.update({"characters": self.character_search_dict(args.get("characters"))}) if args.get("h"): - val = "" - if isinstance(args.get("h"), str): - val = args.get("h").split(",") - if isinstance(args.get("h"), list): - val = args.get("h") - config["h"] = val - - config["help"] = True if (args.get("help") in present) else False - - config["limit"] = ( - int(args.get("limit")) - if args.get("limit") or args.get("limit") == 0 - else 100 - ) - - config["nulls"] = args.get("nulls") if args.get("nulls") else "first" - - config["pretty"] = True if (args.get("pretty") in present) else False - - config["prune"] = True if (args.get("prune") in present) else False + h_val: str | list = args.get("h") + config.update({"h": h_val.split(",") if isinstance(h_val, str) else h_val}) + + config.update({ + "help": args.get("help") in present, + "limit": int(args.get("limit")) if (args.get("limit") or args.get("limit") == 0) else 100, + "nulls": args.get("nulls") or "first", + "pretty": args.get("pretty") in present, + "prune": args.get("prune") in present, + "universe": args.get("universe"), + }) if "random" in args: - config["random"] = True if (args.get("random") in present) else False + config.update({"random": args.get("random") in present}) if args.get("s"): - config["s"] = self.sort_dict(args.get("s")) + config.update({"s": self.sort_dict(args.get("s"))}) if "seed" in args: - config["seed"] = True if (args.get("seed") in present) else False - - config["universe"] = args.get("universe") + config.update({"seed": args.get("seed") in present}) self.config = config - return config - def character_search_dict(self, characters): + def character_search_dict(self, characters: str | list | dict) -> dict: """ - Breaks up Search pattern into recognizable search filter dictionary to processing during filtering stage - e.g. spider+man or spider-man,-616 - :param characters: (str) Character Search String - :return: (dict) list of search options + Break up a search pattern into a recognisable filter dictionary. + e.g. ``spider+man`` or ``spider-man,-616`` + :param characters: Character search string. + :return: Dict with keys ``some``, ``every``, and ``exclude``. """ search_list = self.handle_param_types(characters) search = dict() @@ -155,11 +137,8 @@ def character_search_dict(self, characters): search["some"] = search["some"] + chars return search - def show_help(self): - """ - Helper method to display help text - :return: (text) Text based help - """ + def show_help(self) -> str: + """Return help text appropriate to the current config.""" if not self.config.get("characters"): return self.help_base else: @@ -167,10 +146,10 @@ def show_help(self): - def sort_dict(self, sort_str): + def sort_dict(self, sort_str: str | list | dict) -> list: """ - Method converts a specially formatted query param into a list of dictionaries - s=name,appearances:desc becomes + Convert a specially formatted query param into a list of dicts. + ``s=name,appearances:desc`` becomes [{ "column": "name", "sort": False, diff --git a/service/utils/common.py b/service/utils/common.py index 8bd7ad5..34060e6 100644 --- a/service/utils/common.py +++ b/service/utils/common.py @@ -3,26 +3,25 @@ class ServiceUtils: """ - ServiceUtils Class - Sorting Direction helper - Param Type normalization - Permutations on names + ServiceUtils + Sorting direction helper, param-type normalisation, name permutations. """ - def __init__(self): + + def __init__(self) -> None: return @staticmethod - def direction(val): + def direction(val: str) -> bool: """ Internal Method that determines sort direction reverse=False|True :param val: asc or dsc :return: Boolean """ - return False if val == "asc" else True + return val != "asc" @staticmethod - def handle_param_types(param): + def handle_param_types(param: str | list | dict) -> list: """ Parameters can come in several different formats. This private method tests for the format and prepares it accordingly @@ -40,7 +39,7 @@ def handle_param_types(param): return response @staticmethod - def permutate(names): + def permutate(names: str | list) -> list: """ Will attempt to make permutations on names passed in so empty result sets are limited e.g. spider man, spider-man, spiderman diff --git a/service/utils/error.py b/service/utils/error.py index c31111a..7cbf106 100644 --- a/service/utils/error.py +++ b/service/utils/error.py @@ -1,19 +1,16 @@ class InvalidUsage(Exception): - """ - InvalidUsage Class - Handles Error messaging normalization for Flask + InvalidUsage + Normalizes application errors into a JSON-serialisable dictionary. """ + status_code = 400 - def __init__(self, message, status_code=None, payload=None): + def __init__(self, message: object, status_code: int | None = None, payload: object = None) -> None: """ - Normalizes Flask Error code is JSON - https://flask.palletsprojects.com/en/master/errorhandling/#returning-api-errors-as-json - - :param message: - :param status_code: - :param payload: + :param message: Human-readable error description. + :param status_code: HTTP status code to return (default 400). + :param payload: Optional extra data to include in the response body. """ Exception.__init__(self) self.message = message @@ -21,13 +18,8 @@ def __init__(self, message, status_code=None, payload=None): self.status_code = status_code self.payload = payload - def to_dict(self): - """ - Normalizes the error message in a dictionary - - :return: - """ + def to_dict(self) -> dict[str, object]: + """Return the error as a plain dictionary.""" rv = dict(self.payload or ()) rv["message"] = str(self.message) - return rv diff --git a/service/utils/file.py b/service/utils/file.py index c94859b..1bfc60d 100644 --- a/service/utils/file.py +++ b/service/utils/file.py @@ -8,7 +8,7 @@ class ReadFile: - def __init__(self, cfg): + def __init__(self, cfg: dict) -> None: self.character_sets = { "dc": "dc-wikia-data_csv.csv", "marvel": "marvel-wikia-data_csv.csv", @@ -19,7 +19,7 @@ def __init__(self, cfg): self.total_data = 0 self.universe = cfg.get("universe") - def filter_characters(self, data): + def filter_characters(self, data: list) -> list: """ s = ['superman', 'super man', 'super-man'] (some) e = ['super', 'man'] (all/every) @@ -45,7 +45,7 @@ def filter_characters(self, data): return data_in_play - def filter_data(self, data): + def filter_data(self, data: list) -> list: """ Filter data set based on params :param data: (list) List of "fixed" data meaning Type coercions, unicode decoding etc @@ -71,7 +71,7 @@ def filter_data(self, data): return results - def filter_limit(self, data): + def filter_limit(self, data: list) -> list: """ Filter data based on Limits :param data: (list) List of Filtered Character data @@ -102,7 +102,7 @@ def filter_limit(self, data): return data_in_play - def get_data(self): + def get_data(self) -> list: """ This method will open the correct file and 1. Coerce data types to the correct type @@ -183,10 +183,40 @@ def get_data(self): return self.filter_data(dl) - def sort_results(self, results, srt_ordr=None): + def sort_i18n_str(self, row: dict, sort_col: str, sort_dir: bool) -> tuple: """ - Custom sorting function that allows for sorting None while also maintaining locale aware sorting - config["nulls"] will allow for sorting None and putting them at the front or end of the list + Locale-aware sort key function for international strings (diacritics). + Uses locale.strxfrm so that e.g. 'Ê' sorts alongside 'e'. + + None handling respects config["nulls"] ("first"|"last") and sort direction + (sort_dir=False → ascending / reverse=False, sort_dir=True → descending / reverse=True). + + :param row: A single character dict from the result set. + :param sort_col: The column key to sort on. + :param sort_dir: True = descending (reverse=True), False = ascending (reverse=False). + :return: A tuple used as the sort key. + """ + itm = row[sort_col] + + if itm is not None and isinstance(itm, str): + itm = locale.strxfrm(itm) + + # Sorting None must also survive sort direction (asc|desc) i.e. reverse=True|False + if self.config.get("nulls") == "first" and not self.config.get("prune"): + if not sort_dir: + return (itm is not None, itm != "", itm) + else: + return (itm is None, itm != "", itm) + else: + if not sort_dir: + return (itm is None, itm != "", itm) + else: + return (itm is not None, itm != "", itm) + + def sort_results(self, results: list, srt_ordr: list | None = None) -> list: + """ + Custom sorting function that allows for sorting None while also maintaining locale aware sorting. + config["nulls"] will allow for sorting None and putting them at the front or end of the list. l = [1, 3, 2, 5, 4, None, 7] print('Last = ', sorted(l, key=lambda x: (x is None, x))) @@ -194,40 +224,16 @@ def sort_results(self, results, srt_ordr=None): print('First = ', sorted(l, key=lambda x: (x is not None, x))) First = [None, 1, 2, 3, 4, 5, 7] - :param results: (list) e.g. [{'name': 'richard jones (earth-616)', 'appearances': 590, 'year': 1962}] - :param srt_ordr: (dict) e.g. {'column': 'name', 'sort': False} - :return: (tuple) e.g. (False, True, 1969) tuples are sorted by item type, this means that all non-None elements - will come first (since False < True), and then be sorted by value. + :param results: (list) e.g. [{'name': 'richard jones (earth-616)', 'appearances': 590, 'year': 1962}] + :param srt_ordr: (list) e.g. [{'column': 'name', 'sort': False}] + :return: Sorted list. """ srt_dict = srt_ordr if srt_ordr is not None else self.config.get("s") for i in reversed(srt_dict): - def sort_i18n_str(row): - """ - A closure to make debugging thins a little easier to debug than a lambda function - in the direction list - sort = True|False is referring to reverse in the python sort method - :param row: (dict) dictionary with keys sort on - :return: - """ - itm = row[i["column"]] - - if itm is not None and isinstance(itm, str): - itm = locale.strxfrm(row[i["column"]]) - - # Sorting None must also survive sort direction (asc|desc) or reverse=True|False - if self.config.get("nulls") == "first" and not self.config.get("prune"): - # if sort = False (meaning reverse=False meaning sort = ASC = A => Z) - if not i["sort"]: - srt_tpl = (itm is not None, itm != "", itm) - else: - srt_tpl = (itm is None, itm != "", itm) - else: - if not i["sort"]: - srt_tpl = (itm is None, itm != "", itm) - else: - srt_tpl = (itm is not None, itm != "", itm) - - return srt_tpl - results.sort(key=sort_i18n_str, reverse=i["sort"]) + results.sort( + key=lambda row, col=i["column"], d=i["sort"]: self.sort_i18n_str(row, col, d), + reverse=i["sort"], + ) return results \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c159a32..0000000 --- a/setup.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[tool:pytest] -testpaths = tests - -[coverage:run] -branch = True -source = - service diff --git a/setup.py b/setup.py deleted file mode 100644 index 2e90abe..0000000 --- a/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -from setuptools import find_packages -from setuptools import setup - -setup( - name="simple_superhero_service", - packages=find_packages(), - include_package_data=True, - install_requires=["flask", "confuse", "dotenv"], - extras_require={"test": ["pytest", "coverage"]}, -) diff --git a/MANIFEST.in b/tests/.this_is_a_git_submodule similarity index 100% rename from MANIFEST.in rename to tests/.this_is_a_git_submodule diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c4f8571 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,9 @@ +# Tests + +This directory is reserved as a git submodule for Postman / API integration tests. + +To initialise after cloning: + +```bash +git submodule update --init --recursive +``` diff --git a/tests/test_factory.py b/tests/test_factory.py deleted file mode 100644 index 40fbbb4..0000000 --- a/tests/test_factory.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from service import create_app -from service.utils.error import InvalidUsage - - -def test_config(): - assert not create_app().testing - assert create_app({"TESTING": True}).testing - - -def test_no_config_file(): - assert create_app(environment="foo").config.get("TEST_STRING") == "DEFAULT HELLO" - - -def test_config_file(): - assert ( - create_app(environment="production").config.get("TEST_STRING") - == "PRODUCTION HELLO" - ) - - -def test_healthcheck(client): - response = client.get("/healthcheck") - assert response.status_code == 200 - assert response.data == b'{"status": "Ok"}' diff --git a/tests/conftest.py b/unit/conftest.py similarity index 96% rename from tests/conftest.py rename to unit/conftest.py index cc7349e..200b4b4 100644 --- a/tests/conftest.py +++ b/unit/conftest.py @@ -1,7 +1,9 @@ import pytest -from service import create_app +from fastapi.testclient import TestClient from urllib.parse import urlparse, parse_qsl +from service import create_app + def normalize_url(url, universe="marvel"): url_dict = dict(parse_qsl(urlparse(url).query, keep_blank_values=True)) @@ -12,18 +14,12 @@ def normalize_url(url, universe="marvel"): @pytest.fixture def app(): - app = create_app({"TESTING": True}) - yield app + return create_app() @pytest.fixture def client(app): - return app.test_client() - - -@pytest.fixture -def runner(app): - return app.test_cli_runner() + return TestClient(app, raise_server_exceptions=False) @pytest.fixture @@ -155,3 +151,4 @@ def common_config_options(request): if request is not None and data.get(request.param): val = data[request.param] return val + diff --git a/tests/test_api.py b/unit/test_api.py similarity index 97% rename from tests/test_api.py rename to unit/test_api.py index 60f8900..e69710d 100644 --- a/tests/test_api.py +++ b/unit/test_api.py @@ -158,15 +158,15 @@ def test_help_search(universe, character): ) def test_show_help_no_characters(client, endpoint, expected): response = client.get(f"{endpoint}/?help") - data = response.data.decode("utf8") + data = response.text assert expected in data @pytest.mark.parametrize( - ("endpoint", "character"), [("/marvel/", "spider-man"), ("/dc/", "batman")], + ("endpoint", "character"), [("/marvel", "spider-man"), ("/dc", "batman")], ) def test_show_help_with_characters(client, endpoint, character): response = client.get(f"{endpoint}/{character}/?help") - data = response.data.decode("utf8") + data = response.text assert character in data @pytest.mark.parametrize( diff --git a/tests/test_common.py b/unit/test_common.py similarity index 100% rename from tests/test_common.py rename to unit/test_common.py diff --git a/tests/test_dc.py b/unit/test_dc.py similarity index 66% rename from tests/test_dc.py rename to unit/test_dc.py index 184f3a0..08f29e0 100644 --- a/tests/test_dc.py +++ b/unit/test_dc.py @@ -1,16 +1,11 @@ import pytest -import json - - -def bytes_to_json(response): - return json.loads(response.data.decode("utf8").replace("'", '"')) @pytest.mark.parametrize( - ("endpoint", "response_code"), [("/dc", 308)], + ("endpoint", "response_code"), [("/dc", 307)], ) def test_no_trailing_slash(client, endpoint, response_code): - response = client.get(endpoint) + response = client.get(endpoint, follow_redirects=False) assert response.status_code == response_code @@ -19,7 +14,7 @@ def test_no_trailing_slash(client, endpoint, response_code): ) def test_trailing_slash(client, endpoint, response_code): response = client.get(endpoint) - data = bytes_to_json(response) + data = response.json() assert response.status_code == response_code assert len(data) == 100 @@ -29,7 +24,7 @@ def test_trailing_slash(client, endpoint, response_code): ) def test_get_data_missing_file(client, endpoint, error_msg): response = client.get(f"{endpoint}?universe=foo") - data = bytes_to_json(response) + data = response.json() assert data.get("message") == error_msg @@ -38,7 +33,7 @@ def test_get_data_missing_file(client, endpoint, error_msg): ) def test_get_data_with_character(client, endpoint): response = client.get(endpoint, follow_redirects=True) - data = bytes_to_json(response) + data = response.json() assert len(data) != 0 @@ -47,7 +42,7 @@ def test_get_data_with_character(client, endpoint): ) def test_get_help(client, endpoint, character): response = client.get(f"{endpoint}/{character}/?help") - data = response.data.decode("utf8") + data = response.text assert character in data @@ -56,7 +51,7 @@ def test_get_help(client, endpoint, character): ) def test_get_pretty(client, endpoint, character): response = client.get(f"{endpoint}?pretty") - assert response.headers["Content-Type"] == "application/json" + assert "application/json" in response.headers["Content-Type"] @pytest.mark.parametrize( @@ -65,5 +60,23 @@ def test_get_pretty(client, endpoint, character): ) def test_post_endpoint(client, endpoint, payload, expected): response = client.post(f"{endpoint}", json=payload) - data = bytes_to_json(response) + data = response.json() assert data[0].get("name") == expected + + +@pytest.mark.parametrize( + ("endpoint", "params"), + [ + ("/dc/", {"limit": "5"}), + ("/dc/", {"h": "name,appearances"}), + ("/dc/", {"s": "name:asc"}), + ("/dc/", {"nulls": "last"}), + ("/dc/", {"format": "json"}), + ], +) +def test_get_query_params(client, endpoint, params): + response = client.get(endpoint, params=params) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + diff --git a/tests/test_error.py b/unit/test_error.py similarity index 100% rename from tests/test_error.py rename to unit/test_error.py diff --git a/unit/test_factory.py b/unit/test_factory.py new file mode 100644 index 0000000..0ad09a7 --- /dev/null +++ b/unit/test_factory.py @@ -0,0 +1,31 @@ +import pytest +from fastapi.testclient import TestClient +from service import create_app +from service.config import Settings, get_settings +from service.utils.error import InvalidUsage + + +def test_config(): + app = create_app() + assert app is not None + + +def test_healthcheck(client): + response = client.get("/healthcheck") + assert response.status_code == 200 + assert response.json() == {"status": "Ok"} + + +def test_settings_defaults(): + settings = Settings() + assert settings.host == "127.0.0.1" + assert settings.port == 8000 + assert isinstance(settings.debug, bool) + + +def test_get_settings_cached(): + get_settings.cache_clear() + s1 = get_settings() + s2 = get_settings() + assert s1 is s2 + diff --git a/tests/test_file.py b/unit/test_file.py similarity index 100% rename from tests/test_file.py rename to unit/test_file.py diff --git a/tests/test_marvel.py b/unit/test_marvel.py similarity index 66% rename from tests/test_marvel.py rename to unit/test_marvel.py index 96ba837..119967b 100644 --- a/tests/test_marvel.py +++ b/unit/test_marvel.py @@ -1,16 +1,11 @@ import pytest -import json - - -def bytes_to_json(response): - return json.loads(response.data.decode("utf8").replace("'", '"')) @pytest.mark.parametrize( - ("endpoint", "response_code"), [("/marvel", 308)], + ("endpoint", "response_code"), [("/marvel", 307)], ) def test_no_trailing_slash(client, endpoint, response_code): - response = client.get(endpoint) + response = client.get(endpoint, follow_redirects=False) assert response.status_code == response_code @@ -19,7 +14,7 @@ def test_no_trailing_slash(client, endpoint, response_code): ) def test_trailing_slash(client, endpoint, response_code): response = client.get(endpoint) - data = bytes_to_json(response) + data = response.json() assert response.status_code == response_code assert len(data) == 100 @@ -29,7 +24,7 @@ def test_trailing_slash(client, endpoint, response_code): ) def test_get_data_missing_file(client, endpoint, error_msg): response = client.get(f"{endpoint}?universe=foo") - data = bytes_to_json(response) + data = response.json() assert data.get("message") == error_msg @@ -38,7 +33,7 @@ def test_get_data_missing_file(client, endpoint, error_msg): ) def test_get_data_with_character(client, endpoint): response = client.get(endpoint, follow_redirects=True) - data = bytes_to_json(response) + data = response.json() assert len(data) != 0 @@ -47,7 +42,7 @@ def test_get_data_with_character(client, endpoint): ) def test_get_help(client, endpoint, character): response = client.get(f"{endpoint}/{character}/?help") - data = response.data.decode("utf8") + data = response.text assert character in data @@ -56,7 +51,8 @@ def test_get_help(client, endpoint, character): ) def test_get_pretty(client, endpoint, character): response = client.get(f"{endpoint}?pretty") - assert response.headers["Content-Type"] == "application/json" + assert "application/json" in response.headers["Content-Type"] + @pytest.mark.parametrize( @@ -65,5 +61,22 @@ def test_get_pretty(client, endpoint, character): ) def test_post_endpoint(client, endpoint, payload, expected): response = client.post(f"{endpoint}", json=payload) - data = bytes_to_json(response) + data = response.json() assert data[0].get("name") == expected + + +@pytest.mark.parametrize( + ("endpoint", "params"), + [ + ("/marvel/", {"limit": "5"}), + ("/marvel/", {"h": "name,appearances"}), + ("/marvel/", {"s": "name:asc"}), + ("/marvel/", {"nulls": "last"}), + ("/marvel/", {"format": "json"}), + ], +) +def test_get_query_params(client, endpoint, params): + response = client.get(endpoint, params=params) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5a5d204 --- /dev/null +++ b/uv.lock @@ -0,0 +1,895 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[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/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]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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/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.optional-dependencies] +trio = [ + { name = "trio" }, +] + +[[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 = "black" +version = "26.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/5f/25b7b149b8b7d3b958efa4faa56446560408c0f2651108a517526de0320a/black-26.3.0.tar.gz", hash = "sha256:4d438dfdba1c807c6c7c63c4f15794dda0820d2222e7c4105042ac9ddfc5dd0b", size = 664127, upload-time = "2026-03-06T17:42:33.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/76/b21711045b7f4c4f1774048d0b34dd10a265c42255658b251ce3303ae3c7/black-26.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2b1e5eec220b419e3591a0aaa6351bd3a9c01fe6291fbaf76d84308eb7a2ede", size = 1895944, upload-time = "2026-03-06T17:46:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c3/8c56e73283326bc92a36101c660228fff09a2403a57a03cacf3f7f84cf62/black-26.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bab64de70bccc992432bee56cdffbe004ceeaa07352127c386faa87e81f9261", size = 1718669, upload-time = "2026-03-06T17:46:26.639Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/712a3ae8f17c1f3cd6f9ac2fffb167a27192f5c7aba68724e8c4ab8474ad/black-26.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b6c5f734290803b7b26493ffd734b02b72e6c90d82d45ac4d5b862b9bdf7720", size = 1794844, upload-time = "2026-03-06T17:46:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5b/ee955040e446df86473287dd24dc69c80dd05e02cc358bca90e22059f7b1/black-26.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7c767396af15b54e1a6aae99ddf241ae97e589f666b1d22c4b6618282a04e4ca", size = 1420461, upload-time = "2026-03-06T17:46:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/12/77/40b8bd44f032bb34c9ebf47ffc5bb47a2520d29e0a4b8a780ab515223b5a/black-26.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:765fd6ddd00f35c55250fdc6b790c272d54ac3f44da719cc42df428269b45980", size = 1229667, upload-time = "2026-03-06T17:46:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/21a834ce3de02c64221243f2adac63fa3c3f441efdb3adbf4136b33dfeb0/black-26.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59754fd8f43ef457be190594c07a52c999e22cb1534dc5344bff1d46fdf1027d", size = 1895195, upload-time = "2026-03-06T17:46:33.12Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/212d9697dd78362dadb778d4616b74c8c2cf7f2e4a55aac2adeb0576f2e9/black-26.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1fd94cfee67b8d336761a0b08629a25938e4a491c440951ce517a7209c99b5ff", size = 1718472, upload-time = "2026-03-06T17:46:34.576Z" }, + { url = "https://files.pythonhosted.org/packages/a2/dd/da980b2f512441375b73cb511f38a2c3db4be83ccaa1302b8d39c9fa2dff/black-26.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b3e653a90ca1ef4e821c20f8edaee80b649c38d2532ed2e9073a9534b14a7", size = 1793741, upload-time = "2026-03-06T17:46:36.261Z" }, + { url = "https://files.pythonhosted.org/packages/93/11/cd69ae8826fe3bc6eaf525c8c557266d522b258154a2968eb46d6d25fac7/black-26.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f8fb9d7c2496adc83614856e1f6e55a9ce4b7ae7fc7f45b46af9189ddb493464", size = 1422522, upload-time = "2026-03-06T17:46:37.607Z" }, + { url = "https://files.pythonhosted.org/packages/75/f5/647cf50255203eb286be197925e86eedc101d5409147505db3e463229228/black-26.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e8618c1d06838f56afbcb3ffa1aa16436cec62b86b38c7b32ca86f53948ffb91", size = 1231807, upload-time = "2026-03-06T17:46:39.072Z" }, + { url = "https://files.pythonhosted.org/packages/ff/77/b197e701f15fd694d20d8ee0001efa2e29eba917aa7c3610ff7b10ae0f88/black-26.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0c6f64ead44f4369c66f1339ecf68e99b40f2e44253c257f7807c5a3ef0ca32", size = 1889209, upload-time = "2026-03-06T17:46:40.453Z" }, + { url = "https://files.pythonhosted.org/packages/93/85/b4d4924ac898adc2e39fc7a923bed99797535bc16dea4bc63944c3903c2b/black-26.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed6f0809134e51ec4a7509e069cdfa42bf996bd0fd1df6d3146b907f36e28893", size = 1720830, upload-time = "2026-03-06T17:46:42.009Z" }, + { url = "https://files.pythonhosted.org/packages/00/b1/5c0bf29fe5b43fcc6f3e8480c6566d21a02d4e702b3846944e7daa06dea9/black-26.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc6ac0ea5dd5fa6311ca82edfa3620cba0ed0426022d10d2d5d39aedbf3e1958", size = 1787676, upload-time = "2026-03-06T17:46:43.382Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/cc8cf14806c144d6a16512272c537d5450f50675d3e8c038705430e90fd9/black-26.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:884bc0aefa96adabcba0b77b10e9775fd52d4b766e88c44dc6f41f7c82787fc8", size = 1445406, upload-time = "2026-03-06T17:46:44.948Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bb/049ea0fad9f8bdec7b647948adcf74bb720bd71dcb213decd553e05b2699/black-26.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:be3bd02aab5c4ab03703172f5530ddc8fc8b5b7bb8786230e84c9e011cee9ca1", size = 1257945, upload-time = "2026-03-06T17:46:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/39/d7/7360654ba4f8b41afcaeb5aca973cfea5591da75aff79b0a8ae0bb8883f6/black-26.3.0-py3-none-any.whl", hash = "sha256:e825d6b121910dff6f04d7691f826d2449327e8e71c26254c030c4f3d2311985", size = 206848, upload-time = "2026-03-06T17:42:31.133Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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/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]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +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/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 = "fastapi" +version = "0.135.1" +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/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +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/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.3.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/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-settings" +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/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/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]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +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/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 = "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/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/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 = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +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 = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "simple-superhero-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "anyio", extra = ["trio"] }, + { name = "black" }, + { name = "coverage" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", extras = ["trio"], marker = "extra == 'dev'", specifier = ">=4.7.0" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=24.10.0" }, + { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "sniffio" +version = "1.3.1" +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" } +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" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "starlette" +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/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/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 = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +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/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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/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 = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +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/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] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +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/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.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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/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 = "16.0" +source = { registry = "https://pypi.org/simple" } +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/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" }, +] From e4c1911d89ae87a188c0c3d58bc890ec4f8c5884 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Fri, 13 Mar 2026 15:25:19 -0400 Subject: [PATCH 02/21] newline --- service/utils/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/utils/file.py b/service/utils/file.py index 1bfc60d..5c1ca4e 100644 --- a/service/utils/file.py +++ b/service/utils/file.py @@ -236,4 +236,4 @@ def sort_results(self, results: list, srt_ordr: list | None = None) -> list: reverse=i["sort"], ) - return results \ No newline at end of file + return results From 1aa23db78a1d570626f739b911e7f9411a210d5c Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:41:53 -0400 Subject: [PATCH 03/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/service/models.py b/service/models.py index 5ec7e88..06d13c4 100644 --- a/service/models.py +++ b/service/models.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field @@ -43,7 +43,10 @@ class CharacterSearchBody(BaseModel): h: list[str] | str | None = Field(None, description="Headers to display. Either a string or Array of strings") help: bool | None = Field(None, description=f"List available options. {_TF_TEXT}") limit: int | None = Field(None, description="Limit result set. '0' for no limit") - nulls: str | None = Field(None, description=f"Sort null values first or last in order. {_TF_TEXT}") + nulls: Literal["first", "last"] | None = Field( + None, + description='Sort null values either "first" or "last" in the order.', + ) pretty: bool | str | None = Field(None, description=f"Pretty print the result set. {_TF_TEXT}") prune: bool | str | None = Field(None, description=f"Remove keys with null values. {_TF_TEXT}") random: bool | str | None = Field(None, description=f"Returns array of random superheros based on limit. {_TF_TEXT}") From 0eea373d7f7015b91a7452dacaf7400e436cc5c6 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:44:12 -0400 Subject: [PATCH 04/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index 0af0df4..16e6c64 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -139,6 +139,9 @@ def _respond(api: ApiUtils, config: dict): try: data = ReadFile(config).get_data() except (TypeError, InvalidUsage) as error: + if isinstance(error, InvalidUsage): + # Preserve original InvalidUsage, including its status_code and payload + raise raise InvalidUsage(error) if config.get("pretty"): From 23813a7038100386bce09741162c6de11859a6dd Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:45:06 -0400 Subject: [PATCH 05/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index 16e6c64..133ad62 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -7,7 +7,7 @@ from typing import Annotated, Optional from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse, Response from ..models import CharacterSearchBody from ..utils import ApiUtils, InvalidUsage, ReadFile @@ -145,11 +145,16 @@ def _respond(api: ApiUtils, config: dict): raise InvalidUsage(error) if config.get("pretty"): - body = json.dumps(data, indent=4, separators=(",", ": "), sort_keys=False, ensure_ascii=False) - else: - body = json.dumps(data, sort_keys=False, ensure_ascii=False) - - return JSONResponse(content=json.loads(body)) + body = json.dumps( + data, + indent=4, + separators=(",", ": "), + sort_keys=False, + ensure_ascii=False, + ) + return Response(content=body, media_type="application/json") + + return JSONResponse(content=data) @bp_marvel.get( From ea8a08265ce325ddbbd0165d86e109f3eb7b2c11 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:51:17 -0400 Subject: [PATCH 06/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/dc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/routes/dc.py b/service/routes/dc.py index 5718e82..cccde7d 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -160,7 +160,7 @@ def dc_get_base( h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, - nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + nulls: Annotated[Optional[str], Query(description="Sort null values first or last in order. Accepted values: 'first' or 'last'.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, From b8b085cfa633cb1df0c9c1b7025d2313386b9cb5 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:54:01 -0400 Subject: [PATCH 07/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/dc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/service/routes/dc.py b/service/routes/dc.py index cccde7d..22ae86a 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -137,7 +137,11 @@ def _respond(api: ApiUtils, config: dict): try: data = ReadFile(config).get_data() - except (TypeError, InvalidUsage) as error: + except InvalidUsage: + # Preserve existing InvalidUsage exceptions without re-wrapping + raise + except TypeError as error: + # Wrap unexpected TypeError in InvalidUsage raise InvalidUsage(error) if config.get("pretty"): From a9dc4af0289135164aac5ee87c3a789da14b427b Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:56:46 -0400 Subject: [PATCH 08/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index 133ad62..dc4d14e 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -49,14 +49,14 @@ --- -**character: character can be a string, or an array of strings (preferred)** e.g. +**characters: characters can be a string, or an array of strings (preferred)** e.g. ```json -{ "character": "spider-man,iron man" } +{ "characters": "spider-man,iron man" } ``` OR ```json -{ "character": ["spider-man", "iron man"] } +{ "characters": ["spider-man", "iron man"] } ``` **h: h can be a string, or an array (preferred)** e.g. From 801848ff690e38761eafe82140db46d06c65496f Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:57:43 -0400 Subject: [PATCH 09/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index dc4d14e..d02e3ea 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -164,9 +164,9 @@ def _respond(api: ApiUtils, config: dict): description=_BASE_DESCRIPTION, ) def marvel_get_base( - characters: Annotated[Optional[str], Query(description="Character(s) to search for. Either a string or Array of strings.")] = None, + characters: Annotated[Optional[str], Query(description="Character(s) to search for as a string value (e.g. a single name or a comma-separated list).")] = None, format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, - h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + h: Annotated[Optional[str], Query(description="Headers to display as a string value (e.g. a single header or a comma-separated list).")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, From c5280ec5e84b955927ace04764fc3329127315b9 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 20:59:02 -0400 Subject: [PATCH 10/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index d02e3ea..d05b5be 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -4,7 +4,7 @@ import json import re -from typing import Annotated, Optional +from typing import Annotated, Literal, Optional from fastapi import APIRouter, Query from fastapi.responses import JSONResponse, PlainTextResponse, Response @@ -169,7 +169,7 @@ def marvel_get_base( h: Annotated[Optional[str], Query(description="Headers to display as a string value (e.g. a single header or a comma-separated list).")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, - nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + nulls: Annotated[Optional[Literal["first", "last"]], Query(description="Sort null values either 'first' or 'last' in the sort order.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, From 5f9769920b66946cd665ced1d8c60dcf89dc6a51 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:04:11 -0400 Subject: [PATCH 11/21] issues with unused imports, documentation, codacy false positives --- .remarkrc.yml | 4 ++++ Dockerfile | 9 ++++++++- pyproject.toml | 3 +++ unit/test_factory.py | 3 --- 4 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .remarkrc.yml diff --git a/.remarkrc.yml b/.remarkrc.yml new file mode 100644 index 0000000..8fee6d6 --- /dev/null +++ b/.remarkrc.yml @@ -0,0 +1,4 @@ +plugins: + - remark-lint + - - remark-lint-ordered-list-marker-value + - off diff --git a/Dockerfile b/Dockerfile index 1ee716a..2634f1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 # Tell uv to install into the system Python inside the container ENV UV_SYSTEM_PYTHON=1 +# TLS certificate paths — leave empty for plain HTTP, set both for HTTPS +ENV SSL_CERT="" +ENV SSL_KEY="" WORKDIR /home/appuser/service @@ -44,7 +47,11 @@ COPY --chown=appuser:appuser . . EXPOSE ${PORT} HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD curl -fsk https://localhost:${PORT}/healthcheck || exit 1 + CMD if [ -n "$SSL_CERT" ] && [ -n "$SSL_KEY" ]; then \ + curl -fsk https://localhost:${PORT}/healthcheck; \ + else \ + curl -fs http://localhost:${PORT}/healthcheck; \ + fi || exit 1 # https://github.com/Yelp/dumb-init#usage ENTRYPOINT ["/usr/bin/dumb-init", "--"] diff --git a/pyproject.toml b/pyproject.toml index 74b6512..7f27836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,6 @@ testpaths = ["unit"] [tool.coverage.run] branch = true source = ["service"] + +[tool.bandit] +skips = ["B101"] diff --git a/unit/test_factory.py b/unit/test_factory.py index 0ad09a7..7d2c638 100644 --- a/unit/test_factory.py +++ b/unit/test_factory.py @@ -1,8 +1,5 @@ -import pytest -from fastapi.testclient import TestClient from service import create_app from service.config import Settings, get_settings -from service.utils.error import InvalidUsage def test_config(): From dc10a0a90c695d7c65e942c014378a8cf2e40501 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:13:17 -0400 Subject: [PATCH 12/21] Update service/models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- service/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/models.py b/service/models.py index 06d13c4..ebb51c1 100644 --- a/service/models.py +++ b/service/models.py @@ -49,7 +49,7 @@ class CharacterSearchBody(BaseModel): ) pretty: bool | str | None = Field(None, description=f"Pretty print the result set. {_TF_TEXT}") prune: bool | str | None = Field(None, description=f"Remove keys with null values. {_TF_TEXT}") - random: bool | str | None = Field(None, description=f"Returns array of random superheros based on limit. {_TF_TEXT}") + random: bool | str | None = Field(None, description=f"Returns array of random superheroes based on limit. {_TF_TEXT}") s: list[SortParam | dict[str, Any]] | str | None = Field(None, description="Columns to sort on. Either a string or Array of sort objects") seed: bool | str | None = Field(None, description=f"Keep the same random characters on multiple requests. {_TF_TEXT}") universe: str | None = None From 145e1e09486723d2e54791857908417308a58f1e Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:14:31 -0400 Subject: [PATCH 13/21] Update service/routes/marvel.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index d05b5be..7c5793b 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -208,7 +208,10 @@ def marvel_get_by_character( h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, - nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + nulls: Annotated[ + Optional[str], + Query(description="Sort null values in ordered results; accepted values: 'first' or 'last'."), + ] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, From fb0cf113bfdb21c616559b432b8f4fbbd87d1462 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:14:55 -0400 Subject: [PATCH 14/21] Update service/routes/dc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- service/routes/dc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/routes/dc.py b/service/routes/dc.py index 22ae86a..c4c395d 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -203,7 +203,7 @@ def dc_get_by_character( h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, - nulls: Annotated[Optional[str], Query(description=f"Sort null values first or last in order. {_TF_TEXT}")] = None, + nulls: Annotated[Optional[str], Query(description="Sort null values either 'first' or 'last' in the sort order. Accepted values: 'first', 'last'.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, From bc356c7da8caffc5ce52f8a67bba7de4ab8b121d Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:15:32 -0400 Subject: [PATCH 15/21] Update service/routes/marvel.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- service/routes/marvel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/routes/marvel.py b/service/routes/marvel.py index 7c5793b..fb437dd 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -172,7 +172,7 @@ def marvel_get_base( nulls: Annotated[Optional[Literal["first", "last"]], Query(description="Sort null values either 'first' or 'last' in the sort order.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, - random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, + random: Annotated[Optional[str], Query(description=f"Returns array of random superheroes based on limit. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, From f54e05806317e34a0a5d05585969870d00aaa814 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:15:55 -0400 Subject: [PATCH 16/21] Update service/routes/dc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- service/routes/dc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/routes/dc.py b/service/routes/dc.py index c4c395d..f85fbce 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -167,7 +167,7 @@ def dc_get_base( nulls: Annotated[Optional[str], Query(description="Sort null values first or last in order. Accepted values: 'first' or 'last'.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, - random: Annotated[Optional[str], Query(description=f"Returns array of random superheros based on limit. {_TF_TEXT}")] = None, + random: Annotated[Optional[str], Query(description=f"Returns array of random superheroes based on limit. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, From 2ce67007e9ca7d88d25e1a6c217f292b2932a1e5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:16:37 -0400 Subject: [PATCH 17/21] Initial plan (#20) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> From 3f124e4cd0eff01e80462fc7b4b4cf77dcb105cc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:16:56 -0400 Subject: [PATCH 18/21] [WIP] [WIP] Address feedback on service rework implementation (#21) * Initial plan * Fix POST description in dc.py to use 'characters' instead of 'character' Co-authored-by: morgangraphics <607594+morgangraphics@users.noreply.github.com> Agent-Logs-Url: https://github.com/morgangraphics/simple-superhero-service-python/sessions/adfde50c-baa8-4475-92be-57a754fa9768 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: morgangraphics <607594+morgangraphics@users.noreply.github.com> --- service/routes/dc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/routes/dc.py b/service/routes/dc.py index f85fbce..73da233 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -49,14 +49,14 @@ --- -**character: character can be a string, or an array of strings (preferred)** e.g. +**characters: characters can be a string, or an array of strings (preferred)** e.g. ```json -{ "character": "superman,batman" } +{ "characters": "superman,batman" } ``` OR ```json -{ "character": ["superman", "batman"] } +{ "characters": ["superman", "batman"] } ``` **h: h can be a string, or an array (preferred)** e.g. From 471147bacb10df6989f3ad9ddb8d97219a52c8ec Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 21:40:04 -0400 Subject: [PATCH 19/21] Codacy linting issues --- .pylintrc | 18 ++++++++++++++++++ .remarkrc.yml | 2 +- pyproject.toml | 18 ++++++++++++++++++ service/routes/dc.py | 38 ++++++++++++++++++++++---------------- service/routes/marvel.py | 5 +++-- 5 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..4a29ae7 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,18 @@ +[MAIN] +py-version = 3.12 + +[BASIC] +# h and s are intentional short names that map directly to API query parameters. +good-names = h,s,e,i,j,k,_ + +[DESIGN] +# _build_options and route handlers mirror the full set of API query parameters. +max-args = 15 +max-positional-arguments = 15 + +[MESSAGES CONTROL] +# 'format' and 'help' shadow builtins intentionally — they are public API +# query-parameter names whose spelling cannot be changed without breaking clients. +disable = redefined-builtin, + line-too-long, + missing-module-docstring diff --git a/.remarkrc.yml b/.remarkrc.yml index 8fee6d6..b89928c 100644 --- a/.remarkrc.yml +++ b/.remarkrc.yml @@ -1,4 +1,4 @@ plugins: - remark-lint - - remark-lint-ordered-list-marker-value - - off + - false diff --git a/pyproject.toml b/pyproject.toml index 7f27836..b3d4c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,21 @@ source = ["service"] [tool.bandit] skips = ["B101"] + +[tool.pylint.main] +# Inform Pylint of the actual Python version so built-in generics (list[...], +# dict[...]) and typing additions (Literal, Annotated) are recognised correctly. +py-version = "3.12" + +[tool.pylint.basic] +# h and s are intentional short names that map directly to API query parameters. +good-names = ["h", "s", "e", "i", "j", "k", "_"] + +[tool.pylint.design] +# _build_options mirrors the full set of API query parameters (12 args). +max-args = 15 + +[tool.pylint.messages_control] +# 'format' and 'help' shadow builtins intentionally — they are public API +# query-parameter names whose spelling cannot be changed without breaking clients. +disable = ["redefined-builtin"] diff --git a/service/routes/dc.py b/service/routes/dc.py index 73da233..e65b56a 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -4,10 +4,10 @@ import json import re -from typing import Annotated, Optional +from typing import Annotated, Literal, Optional from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse, Response from ..models import CharacterSearchBody from ..utils import ApiUtils, InvalidUsage, ReadFile @@ -35,7 +35,7 @@ e.g. `?pretty` and `?pretty=true` are functionally equivalent -**character: character filters can used like:** +**characters: character filters can used like:** `{keyword1},{keyword2}` e.g. superman,batman will search for each character individually @@ -119,6 +119,7 @@ def _build_options( }.items(): if val is not None: options[key] = val + # Presence-style flags: key present (even empty string) means True for key, val in { "help": help, "pretty": pretty, @@ -137,19 +138,23 @@ def _respond(api: ApiUtils, config: dict): try: data = ReadFile(config).get_data() - except InvalidUsage: - # Preserve existing InvalidUsage exceptions without re-wrapping - raise - except TypeError as error: - # Wrap unexpected TypeError in InvalidUsage - raise InvalidUsage(error) + except (TypeError, InvalidUsage) as error: + if isinstance(error, InvalidUsage): + # Preserve original InvalidUsage, including its status_code and payload + raise + raise InvalidUsage(error) from error if config.get("pretty"): - body = json.dumps(data, indent=4, separators=(",", ": "), sort_keys=False, ensure_ascii=False) - else: - body = json.dumps(data, sort_keys=False, ensure_ascii=False) + body = json.dumps( + data, + indent=4, + separators=(",", ": "), + sort_keys=False, + ensure_ascii=False, + ) + return Response(content=body, media_type="application/json") - return JSONResponse(content=json.loads(body)) + return JSONResponse(content=data) @bp_dc.get( @@ -159,12 +164,12 @@ def _respond(api: ApiUtils, config: dict): description=_BASE_DESCRIPTION, ) def dc_get_base( - characters: Annotated[Optional[str], Query(description="Character(s) to search for. Either a string or Array of strings.")] = None, + characters: Annotated[Optional[str], Query(description="Character(s) to search for as a string value (e.g. a single name or a comma-separated list).")] = None, format: Annotated[Optional[str], Query(description="Output format (currently only JSON)")] = None, - h: Annotated[Optional[str], Query(description="Headers to display. Either a string or Array of strings")] = None, + h: Annotated[Optional[str], Query(description="Headers to display as a string value (e.g. a single header or a comma-separated list).")] = None, help: Annotated[Optional[str], Query(description=f"List available options. {_TF_TEXT}")] = None, limit: Annotated[Optional[str], Query(description="Limit result set. '0' for no limit")] = None, - nulls: Annotated[Optional[str], Query(description="Sort null values first or last in order. Accepted values: 'first' or 'last'.")] = None, + nulls: Annotated[Optional[Literal["first", "last"]], Query(description="Sort null values either 'first' or 'last' in the sort order.")] = None, pretty: Annotated[Optional[str], Query(description=f"Pretty print the result set. {_TF_TEXT}")] = None, prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, random: Annotated[Optional[str], Query(description=f"Returns array of random superheroes based on limit. {_TF_TEXT}")] = None, @@ -172,6 +177,7 @@ def dc_get_base( seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, ): + """Filterable GET handler for the DC universe base endpoint.""" api = ApiUtils() options = _build_options( characters=characters, diff --git a/service/routes/marvel.py b/service/routes/marvel.py index fb437dd..853fa13 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -35,7 +35,7 @@ e.g. `?pretty` and `?pretty=true` are functionally equivalent -**character: character filters can used like:** +**characters: character filters can used like:** `{keyword1},{keyword2}` e.g. iron man,spider-man will search for each character individually @@ -142,7 +142,7 @@ def _respond(api: ApiUtils, config: dict): if isinstance(error, InvalidUsage): # Preserve original InvalidUsage, including its status_code and payload raise - raise InvalidUsage(error) + raise InvalidUsage(error) from error if config.get("pretty"): body = json.dumps( @@ -177,6 +177,7 @@ def marvel_get_base( seed: Annotated[Optional[str], Query(description=f"Keep the same random characters on multiple requests. {_TF_TEXT}")] = None, universe: Annotated[Optional[str], Query(include_in_schema=False)] = None, ): + """Filterable GET handler for the Marvel universe base endpoint.""" api = ApiUtils() options = _build_options( characters=characters, From d546d1cb3979a5a8ebee0ded713899d7c616a272 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 22:02:04 -0400 Subject: [PATCH 20/21] Codacy linting updates --- .pylintrc | 27 +++++++++++++++++++++++---- service/routes/__init__.py | 2 -- service/routes/dc.py | 2 ++ service/routes/healthcheck.py | 2 -- service/routes/marvel.py | 2 ++ service/utils/file.py | 11 ++++------- unit/conftest.py | 1 - 7 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.pylintrc b/.pylintrc index 4a29ae7..217bc50 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,9 +1,14 @@ +[MASTER] +# Support both old pylint ([MASTER]) and new pylint ([MAIN]) section names. +py-version = 3.12 + [MAIN] py-version = 3.12 [BASIC] # h and s are intentional short names that map directly to API query parameters. -good-names = h,s,e,i,j,k,_ +# bp_* are idiomatic FastAPI APIRouter instance names, not constants. +good-names = h,s,e,i,j,k,_,bp_dc,bp_hc,bp_marvel [DESIGN] # _build_options and route handlers mirror the full set of API query parameters. @@ -11,8 +16,22 @@ max-args = 15 max-positional-arguments = 15 [MESSAGES CONTROL] -# 'format' and 'help' shadow builtins intentionally — they are public API -# query-parameter names whose spelling cannot be changed without breaking clients. +# redefined-builtin: 'format' and 'help' shadow builtins intentionally — they +# are public API query-parameter names that cannot be renamed. +# line-too-long: not enforced in this project. +# missing-module-docstring: modules use file-level docstrings only where needed. +# E0611 (no-name-in-module): false positive — Annotated/Literal exist in +# typing since Python 3.8/3.9; old pylint doesn't know this. +# E1136 (unsubscriptable-object): false positive — list[x]/dict[x] are valid +# in Python 3.9+; old pylint doesn't know this. +# C0326 (bad-whitespace): false positive — PEP 8 requires spaces around '=' +# for annotated function parameters; old pylint flags these incorrectly. +# C0330 (wrong-hanging-indentation): removed in pylint 2.6+ but triggers as a +# false positive in older Codacy pylint builds. disable = redefined-builtin, line-too-long, - missing-module-docstring + missing-module-docstring, + no-name-in-module, + unsubscriptable-object, + bad-whitespace, + wrong-hanging-indentation diff --git a/service/routes/__init__.py b/service/routes/__init__.py index 4550ff0..1fe863f 100644 --- a/service/routes/__init__.py +++ b/service/routes/__init__.py @@ -4,9 +4,7 @@ from .healthcheck import bp_hc from .marvel import bp_marvel - def init_app(app: FastAPI) -> None: app.include_router(bp_dc) app.include_router(bp_hc) app.include_router(bp_marvel) - diff --git a/service/routes/dc.py b/service/routes/dc.py index e65b56a..96a5318 100644 --- a/service/routes/dc.py +++ b/service/routes/dc.py @@ -214,6 +214,7 @@ def dc_get_by_character( prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, ): + """GET handler for DC universe character search by name.""" api = ApiUtils() safe_chars = _sanitize(characters) options = _build_options( @@ -241,6 +242,7 @@ def dc_get_by_character( description=_POST_DESCRIPTION, ) def dc_post(body: CharacterSearchBody): + """POST handler for DC universe character search.""" api = ApiUtils() options = body.model_dump(exclude_none=True) options.setdefault("universe", "dc") diff --git a/service/routes/healthcheck.py b/service/routes/healthcheck.py index be66e97..e5c3577 100644 --- a/service/routes/healthcheck.py +++ b/service/routes/healthcheck.py @@ -8,9 +8,7 @@ bp_hc = APIRouter() - @bp_hc.get("/healthcheck", tags=["healthcheck"], summary="Test if the Service is up", response_model=HealthResponse) def healthcheck() -> HealthResponse: """Simple health check endpoint.""" return HealthResponse(status="Ok") - diff --git a/service/routes/marvel.py b/service/routes/marvel.py index 853fa13..1947b8f 100644 --- a/service/routes/marvel.py +++ b/service/routes/marvel.py @@ -217,6 +217,7 @@ def marvel_get_by_character( prune: Annotated[Optional[str], Query(description=f"Remove keys with null values. {_TF_TEXT}")] = None, s: Annotated[Optional[str], Query(description="Columns to sort on.")] = None, ): + """GET handler for Marvel universe character search by name.""" api = ApiUtils() safe_chars = _sanitize(characters) options = _build_options( @@ -244,6 +245,7 @@ def marvel_get_by_character( description=_POST_DESCRIPTION, ) def marvel_post(body: CharacterSearchBody): + """POST handler for Marvel universe character search.""" api = ApiUtils() options = body.model_dump(exclude_none=True) options.setdefault("universe", "marvel") diff --git a/service/utils/file.py b/service/utils/file.py index 5c1ca4e..e667d8c 100644 --- a/service/utils/file.py +++ b/service/utils/file.py @@ -205,13 +205,10 @@ def sort_i18n_str(self, row: dict, sort_col: str, sort_dir: bool) -> tuple: if self.config.get("nulls") == "first" and not self.config.get("prune"): if not sort_dir: return (itm is not None, itm != "", itm) - else: - return (itm is None, itm != "", itm) - else: - if not sort_dir: - return (itm is None, itm != "", itm) - else: - return (itm is not None, itm != "", itm) + return (itm is None, itm != "", itm) + if not sort_dir: + return (itm is None, itm != "", itm) + return (itm is not None, itm != "", itm) def sort_results(self, results: list, srt_ordr: list | None = None) -> list: """ diff --git a/unit/conftest.py b/unit/conftest.py index 200b4b4..590ac1b 100644 --- a/unit/conftest.py +++ b/unit/conftest.py @@ -151,4 +151,3 @@ def common_config_options(request): if request is not None and data.get(request.param): val = data[request.param] return val - From ca036cd42dadcf017881239d20609e8772dd36a7 Mon Sep 17 00:00:00 2001 From: dm00000 Date: Tue, 24 Mar 2026 22:16:10 -0400 Subject: [PATCH 21/21] Codacy issues --- service/utils/file.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/service/utils/file.py b/service/utils/file.py index e667d8c..9889a27 100644 --- a/service/utils/file.py +++ b/service/utils/file.py @@ -228,9 +228,10 @@ def sort_results(self, results: list, srt_ordr: list | None = None) -> list: srt_dict = srt_ordr if srt_ordr is not None else self.config.get("s") for i in reversed(srt_dict): + col, sort_dir = i["column"], i["sort"] results.sort( - key=lambda row, col=i["column"], d=i["sort"]: self.sort_i18n_str(row, col, d), - reverse=i["sort"], + key=lambda row, col=col, d=sort_dir: self.sort_i18n_str(row, col, d), + reverse=sort_dir, ) return results