diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e7657f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text=auto eol=lf diff --git a/.github/workflows/ci-grants-shared-backend.yml b/.github/workflows/ci-grants-shared-backend.yml new file mode 100644 index 0000000..982a448 --- /dev/null +++ b/.github/workflows/ci-grants-shared-backend.yml @@ -0,0 +1,49 @@ +name: Grants Shared Backend Checks + +on: + workflow_call: + inputs: + skip_checks: + type: boolean + default: false + description: Skip the guts of this workflow, but we have to run the workflow for dependency reasons when calling from CD workflows + pull_request: + paths: + - backend/grants_shared/** + - .github/workflows/ci-grants-shared-backend.yml + +defaults: + run: + working-directory: ./backend/grants_shared + +jobs: + lint-test: + if: ${{ inputs.skip_checks == null || inputs.skip_checks == false }} + name: Grants Shared Lint, Format & Tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Initialize the docker containers + run: make init + + - name: Run format check + run: make format-check + + - name: Run linting + run: make lint + + - name: Run security linting + run: make lint-security + + - name: Start tests + run: make test-coverage + + skip-checks: + if: ${{ inputs.skip_checks != null && inputs.skip_checks == true }} + name: Skip Checks + runs-on: ubuntu-22.04 + steps: + - name: Checks skipped + working-directory: ./ + run: exit 0 diff --git a/.github/workflows/publish-grants-shared-backend.yml b/.github/workflows/publish-grants-shared-backend.yml new file mode 100644 index 0000000..53526cb --- /dev/null +++ b/.github/workflows/publish-grants-shared-backend.yml @@ -0,0 +1,102 @@ +name: Publish Grants Shared Backend Package to PyPi +on: + workflow_dispatch: + +defaults: + run: + working-directory: ./backend/grants_shared + +jobs: + build-grants-shared: + name: Build grants_shared for PyPi + runs-on: ubuntu-22.04 + env: + PY_RUN_APPROACH: "local" + DB_HOST: "localhost" + DB_PORT: 5489 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python 3.14 + run: uv python install 3.14 + + - name: Init + run: make setup-local + + - name: Init DB + run: make init-db + + - name: Run format check + run: make format-check + + - name: Run linting + run: make lint + + - name: Run security linting + run: make lint-security + + - name: Start tests + run: make test-coverage + + - name: Build + run: uv build + + - name: Get Project Version + id: get-version + run: | + PROJECT_VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/') + echo "grants_shared project version is currently: ${PROJECT_VERSION}" + echo "project_version=$PROJECT_VERSION" >> "$GITHUB_OUTPUT" + + # Verify we built the package in the right shape to use as a module + - name: Smoke Test (wheel) + run: | + uv run --isolated --with dist/grants_shared-${{steps.get-version.outputs.project_version}}-py3-none-any.whl tests/grants_shared/smoke_test.py + + - name: Smoke Test (source distribution) + run: | + uv run --isolated --with dist/grants_shared-${{steps.get-version.outputs.project_version}}.tar.gz tests/grants_shared/smoke_test.py + + # Upload the dist so the PyPi integration is isolated from the build. + - name: Upload dist + uses: actions/upload-artifact@v7 + with: + name: grants-shared-dist + path: /home/runner/work/grants-shared/grants-shared/backend/grants_shared/dist + if-no-files-found: error + retention-days: 1 + overwrite: true + + publish-grants-shared: + # Separate dedicated job as we have to grant this one additional write + # privileges against our repo for the id-token, want to isolate that. + # This uses trusted publishing which relies on an OIDC connection + # between Github and PyPi. The PyPi package is configured to only + # allow publishing in this way from exactly this workflow. + # + # https://docs.astral.sh/uv/guides/integration/github/#publishing-to-pypi + # https://docs.pypi.org/trusted-publishers/ + name: Publish grants_shared to PyPi + runs-on: ubuntu-22.04 + needs: + - build-grants-shared + # The PyPi configuration setup will only allow this environment + # to publish to PyPi, any others, including the default will be blocked. + environment: pypi-publish + permissions: + id-token: write + + steps: + - name: Retrieve dist + uses: actions/download-artifact@v7 + with: + name: grants-shared-dist + path: dist/ + + # https://github.com/pypa/gh-action-pypi-publish + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b405582 --- /dev/null +++ b/.gitignore @@ -0,0 +1,161 @@ +# Files ignored because they're binary/unnecessary +.DS_STORE +.idea + +# Files generated by local Mermaid development +documentation/milestones/milestone_dependency_diagram.pdf +documentation/deliverables/deliverable_dependency_diagram.pdf + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +!api/tests/lib +!backend/grants_management_api/tests/lib +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# asdf +.tool-versions + +# vscode +.vscode/settings.json + +# vim +*.swp + +# Terraform plan outputs +*.tfplan + +# Python testing stuff +*__pycache__* + +# Artilery +frontend/tests/artillery/params.json + +# XML validation results +api/src/services/xml_generation/xsds/validation_results.json diff --git a/README.md b/README.md index 612d91e..d17f651 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,15 @@ An up-to-date list of core team members can be found in [MAINTAINERS.md](./MAINT ## Repository Structure - [./.github](./.github) contains Github specific settings files and testing, linting, and CI/CD workflows -- [./api](./api) contains an API built in Python using the Flask library +- [./backend](./backend) contains backend Python code - [./documentation](./documentation) contains project guides, documentation, and decision records - [./frontend](./frontend) contains a web application built using Next.js ## Development -### API +### Backend (Grants Shared) -Documentation for the API is linked to from the [API README.md](./api/README.md). +Documentation for the backend grants shared library is provided in [grants_shared README.md](./backend/grants_shared/README.md). ### Front-end diff --git a/api/README.md b/api/README.md deleted file mode 100644 index 1f343c6..0000000 --- a/api/README.md +++ /dev/null @@ -1 +0,0 @@ -Placeholder until we move the Grants Shared API code over \ No newline at end of file diff --git a/backend/grants_shared/.gitignore b/backend/grants_shared/.gitignore new file mode 100644 index 0000000..7ec90d6 --- /dev/null +++ b/backend/grants_shared/.gitignore @@ -0,0 +1,34 @@ +# Python compiled/optimized files +__pycache__/ +*.py[cod] +*$py.class + +# Python packaging stuff +dist/ +*.egg-info + +# Python testing stuff +.coverage* +coverage.* +.testmondata +.pytest_cache/ + +# Python virtual environments +.venv + +# Environment variables +.env +.envrc +override.env + +# mypy +.mypy_cache + +# VSCode Workspace +*.code-workspace +.vscode + +# All pem/pub/secret keys +*.key +*.pub +*.pem diff --git a/backend/grants_shared/Dockerfile b/backend/grants_shared/Dockerfile new file mode 100644 index 0000000..9376703 --- /dev/null +++ b/backend/grants_shared/Dockerfile @@ -0,0 +1,46 @@ +FROM ghcr.io/hhs/python-base-image:a0609d9@sha256:b7f83174c1b6da6592441a07f3dc3389f881ce5146489ce9286d01d7aa455b9b AS base + +ARG RUN_UID +ARG RUN_USER + +# Read Python version from the version file in the base image +RUN mkdir -p /tmp && cat /python-version.txt > /tmp/py_version.txt + +# Create user and directories +RUN : "${RUN_USER:?RUN_USER and RUN_UID need to be set and non-empty.}" && : "${RUN_UID:?RUN_USER and RUN_UID need to be set and non-empty.}" && \ + if [ "${RUN_USER}" != "root" ]; then \ + grep -Eq "^${RUN_USER}:" /etc/group || groupadd -g "${RUN_UID}" "${RUN_USER}"; \ + grep -Eq "^${RUN_USER}:" /etc/passwd || useradd -l -M -s /bin/bash -u "${RUN_UID}" -g "${RUN_USER}" "${RUN_USER}"; \ + fi && \ + mkdir -p "/home/${RUN_USER}" /grants_shared /grants_shared/tmp /var/spool/mail /tmp /var/tmp /usr/tmp /opt/venv && \ + chown -R "${RUN_UID}:${RUN_UID}" "/home/${RUN_USER}" /grants_shared /grants_shared/tmp /opt/venv && \ + chmod 700 /grants_shared/tmp && \ + chmod 1777 /tmp /var/tmp /usr/tmp /opt/venv + +WORKDIR /grants_shared + +COPY pyproject.toml uv.lock ./ + +RUN rm -rf /grants_shared/.venv || true + +COPY . /grants_shared + +# Prefer external venv to avoid mutating release venv +ENV VIRTUAL_ENV=/opt/venv +ENV UV_PROJECT_ENVIRONMENT=/opt/venv +# Place entry points in the environment at the front of the path +ENV PATH="/opt/venv/bin:$PATH" + +# We are fine with dev dependencies in this +# image as we don't need a release image +ENV UV_NO_DEV=0 +# Compile python to *.pyc files for performance +ENV UV_COMPILE_BYTECODE=1 +ENV UV_CACHE_DIR="/tmp/.cache/uv" +ENV UV_NO_SYNC=1 + +RUN set -e && PY_MM=$(cat /tmp/py_version.txt) && \ + uv venv /opt/venv && \ + uv sync --locked --all-groups && \ + uv build && \ + (rm -rf /root/.cache "/home/${RUN_USER}/.cache" /var/tmp/* /grants_shared/src || true) diff --git a/backend/grants_shared/Makefile b/backend/grants_shared/Makefile new file mode 100644 index 0000000..9f3ceab --- /dev/null +++ b/backend/grants_shared/Makefile @@ -0,0 +1,170 @@ +################################################## +# Constants +################################################## + +APP_NAME := grants-shared + +# Colors for output, can be used as: +# echo -e "this text is the default color $(RED) this text is red $(NO_COLOR) everything here is the default color again" +RED := \033[0;31m +NO_COLOR := \033[0m + +# Required for CI flags below to work properly +SHELL = /bin/bash -o pipefail + +# The APP_DIR variable is the path from the root of the repository to this Makefile. +# This variable is used to display errors from MyPy in the 'Files Changed' +# section of a pull request. If this is set to the incorrect value, you won't be able +# to see the errors on the correct files in that section +APP_DIR := backend\/grants_shared +ifdef CI + DOCKER_EXEC_ARGS := -T -e CI -e PYTEST_ADDOPTS="--color=yes" -e UV_PUBLISH_TOKEN + MYPY_FLAGS := --no-pretty + MYPY_POSTPROC := | perl -pe "s/^(.+):(\d+):(\d+): error: (.*)/::warning file=$(APP_DIR)\/\1,line=\2,col=\3::\4/" +endif + +DOCKER_CMD := docker compose run $(DOCKER_EXEC_ARGS) --rm $(APP_NAME) + +# By default, all python/uv commands will run inside of the docker container +# if you wish to run this natively, add PY_RUN_APPROACH=local to your environment vars +# You can set this by either running `export PY_RUN_APPROACH=local` in your shell or add +# it to your ~/.zshrc file (and run `source ~/.zshrc`) + +ifeq "$(PY_RUN_APPROACH)" "local" +PY_RUN_CMD := uv run +RUN_CMD := +else +PY_RUN_CMD := $(DOCKER_CMD) +RUN_CMD := $(DOCKER_CMD) +endif + +# Docker user configuration +# This logic is to avoid issues with permissions and mounting local volumes, +# which should be owned by the same UID for Linux distros. Mac OS can use root, +# but it is best practice to run things as with least permission where possible + +# Can be set by adding user= and/ or uid= after the make command +# If variables are not set explicitly: try looking up values from current +# environment, otherwise fixed defaults. +# uid= defaults to 0 if user= set (which makes sense if user=root, otherwise you +# probably want to set uid as well). +ifeq ($(user),) +RUN_USER ?= $(or $(strip $(USER)),nodummy) +RUN_UID ?= $(or $(strip $(shell id -u)),4000) +else +RUN_USER = $(user) +RUN_UID = $(or $(strip $(uid)),0) +endif + +export RUN_USER +export RUN_UID + +################################################## +# Local Development Environment Setup +################################################## + +setup-local: + # Install dependencies + uv sync --all-groups --locked + +################################################## +# Build & Run +################################################## + +build: + docker compose build + +init: build init-db + +clean-volumes: ## Remove project docker volumes - which includes the DB + docker compose down --volumes + +volume-recreate: clean-volumes init ## Destroy current volumes, setup new ones - will remove all existing data + +stop: ## Stop the docker containers + docker compose down + +######################### +# DB running / setup +######################### + +init-db: start-db ## Initialize the DB - includes any extra setup + +# Docker starts the image for the DB but it's not quite +# ready to accept connections so we add a brief wait script +start-db: ## Start the DB and wait for it to be available + docker compose up --detach grants-shared-db + ./bin/wait-for-local-db.sh + +################################################## +# Testing +################################################## + +test: ## Run all tests except for audit logging + $(PY_RUN_CMD) pytest -m "not audit" $(args) + +test-audit: ## Run audit logging tests + $(PY_RUN_CMD) pytest -m "audit" $(args) + +test-coverage: + $(PY_RUN_CMD) coverage run --branch --source=src -m pytest -m "not audit" $(args) + $(PY_RUN_CMD) coverage run --data-file=.coverage.audit --branch --source=src -m pytest -m "audit" $(args) + $(PY_RUN_CMD) coverage combine --data-file=.coverage --append + $(PY_RUN_CMD) coverage report + +test-coverage-report: ## Open HTML test coverage report + $(PY_RUN_CMD) coverage html --directory .coverage_report + open .coverage_report/index.html + + +################################################## +# Formatting and linting +################################################## + +format: ## Format files + $(PY_RUN_CMD) isort --atomic src tests + $(PY_RUN_CMD) black src tests + +format-check: ## Check file formatting + $(PY_RUN_CMD) isort --atomic --check-only src tests + $(PY_RUN_CMD) black --check src tests + +lint: lint-py ## Lint + +lint-py: lint-ruff lint-mypy + +lint-ruff: + $(PY_RUN_CMD) ruff check . + +lint-ruff-fix: + $(PY_RUN_CMD) ruff check . --fix + +lint-mypy: + $(PY_RUN_CMD) mypy --show-error-codes $(MYPY_FLAGS) src $(MYPY_POSTPROC) + +lint-security: # https://bandit.readthedocs.io/en/latest/index.html + $(PY_RUN_CMD) bandit -c pyproject.toml -r . --number 3 --skip B101 -ll -x ./.venv + + +################################################## +# Release functionality +################################################## + +bump-patch-version: ## Bump the patch version of the code (eg. 1.0.1 -> 1.0.2) + $(RUN_CMD) uv version --bump patch + +bump-minor-version: ## Bump the minor version of the code (eg. 1.0.1 -> 1.1.0) + $(RUN_CMD) uv version --bump minor + +################################################## +# Misc functionality +################################################## + +docker-cmd: ## Run a command inside docker. Use as 'make docker-cmd args="uv --help"' + $(DOCKER_CMD) $(args) + +run-cmd: ## Run a generic command inside/outside docker based on the PY_RUN_APPROACH env var. Use as 'make run-cmd args="uv --help"' + $(RUN_CMD) $(args) + +help: ## Prints the help documentation and info about each command + @grep -E '^[/a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' \ No newline at end of file diff --git a/backend/grants_shared/README.md b/backend/grants_shared/README.md new file mode 100644 index 0000000..fe35966 --- /dev/null +++ b/backend/grants_shared/README.md @@ -0,0 +1,63 @@ +# Grants Shared + +This repo contains the shared code used by the different backend APIs in: +* [Simpler Grants](https://github.com/HHS/simpler-grants-gov) +* [Smarter Grants Management](https://github.com/HHS/smarter-grants-management) + +This code is not meant to be used outside of these systems. We cannot provide support for anyone who attempts to use this code for other projects. + +[License](https://github.com/HHS/grants-shared/blob/main/LICENSE.md) + +## Installation +You can install this package with any python dependency manager. + +```shell +# Using pip +pip install grants_shared + +# Using poetry +poetry add grants_shared + +# Using uv +uv add grants_shared +``` + +## Release Process + +### Version Upgrade + +When you make changes, upgrade the version of the package. + +```shell +# Upgrade the version with uv +# https://docs.astral.sh/uv/guides/package/#updating-your-version + +# Generally do a patch version (eg. 1.0.1 -> 1.0.2) +uv version --bump patch + +# Or do a minor version for anything fairly big (eg. 1.1.3 -> 1.2.0) +# uv version --bump minor +``` + +### Release to PyPi +After your change has been merged to main, you can +publish a new release in PyPi with our [Github action](https://github.com/HHS/grants-shared/actions/workflows/publish-grants-shared.yml) + + +## Usage +Guidance on common commands and running the application will come in later +versions as we're still getting this setup, but a few basic commands to get you started. + +```shell +# Build the docker image +make build + +# Run tests +make test + +# Formatting and linting +make format +make lint +``` + + diff --git a/backend/grants_shared/bin/wait-for-local-db.sh b/backend/grants_shared/bin/wait-for-local-db.sh new file mode 100755 index 0000000..c844d4e --- /dev/null +++ b/backend/grants_shared/bin/wait-for-local-db.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +# wait-for-local-db + +set -e + +# Color formatting +RED='\033[0;31m' +NO_COLOR='\033[0m' + +MAX_WAIT_TIME=30 # seconds +WAIT_TIME=0 + +# Use pg_isready to wait for the DB to be ready to accept connections +# We check every 3 seconds and consider it failed if it gets to 30+ +# https://www.postgresql.org/docs/current/app-pg-isready.html +until pg_isready -h localhost -d grants-mgmt-db -p 5489 -q; +do + echo "waiting on Postgres DB to initialize..." + sleep 3 + + WAIT_TIME=$(($WAIT_TIME+3)) + if [ $WAIT_TIME -gt $MAX_WAIT_TIME ] + then + echo -e "${RED}ERROR: Database appears to not be starting up, running \"docker logs grants-db\" to troubleshoot.${NO_COLOR}" + docker logs grants-db + exit 1 + fi +done + +echo "Postgres DB is ready after ~${WAIT_TIME} seconds" + + diff --git a/backend/grants_shared/docker-compose.yml b/backend/grants_shared/docker-compose.yml new file mode 100644 index 0000000..233219f --- /dev/null +++ b/backend/grants_shared/docker-compose.yml @@ -0,0 +1,37 @@ +services: + grants-shared: + build: + context: . + target: base + args: + - RUN_UID=${RUN_UID:-4000} + - RUN_USER=${RUN_USER:-grants-shared} + container_name: grants-shared + env_file: ./local.env + volumes: + - .:/grants_shared + # Override the dist folder so the build is isolated + # and doesn't override with what exists outside + - dist:/grants_shared/dist + networks: + - default + + grants-shared-db: + image: postgres:17.5-alpine + container_name: grants-shared-db + command: postgres -c "log_lock_waits=on" -N 1000 -c "fsync=off" + environment: + POSTGRES_USER: grants_shared + POSTGRES_PASSWORD: secret123 + ports: + # NOTE - we map the port differently as it's common our developers + # will do work in the simpler-grants/grants-management code as well which uses the default 5432 port + - "5489:5432" + volumes: + - grantsshareddbdata:/var/lib/postgresql/data + networks: + - default + +volumes: + dist: + grantsshareddbdata: diff --git a/backend/grants_shared/local.env b/backend/grants_shared/local.env new file mode 100644 index 0000000..b419b9b --- /dev/null +++ b/backend/grants_shared/local.env @@ -0,0 +1,78 @@ +############################ +# Logging +############################ + +# Can be "human-readable" OR "json" +LOG_FORMAT=human-readable + +# Set log level. Valid values are DEBUG, INFO, WARNING, CRITICAL +LOG_LEVEL=INFO + +# Enable/disable audit logging. Valid values are TRUE, FALSE +LOG_ENABLE_AUDIT=FALSE + +# Change the message length for the human readable formatter +# LOG_HUMAN_READABLE_FORMATTER__MESSAGE_WIDTH=50 + +LOG_LEVEL_OVERRIDES=smart_open.s3=ERROR + +############################ +# DB Environment Variables +############################ + +# Set DB_HOST to localhost if accessing a non-dockerized database +# Set DB_PORT to 5489 if accessing the DB outside Docker +DB_HOST=grants-shared-db +DB_PORT=5432 +DB_NAME=grants_shared +DB_USER=grants_shared +DB_PASSWORD=secret123 +DB_SSL_MODE=allow + +# When an error occurs with a SQL query, +# whether or not to hide the parameters which +# could contain sensitive information. +HIDE_SQL_PARAMETER_LOGS=TRUE + +ALL_DB_SCHEMAS=grants_shared,other + +############################ +# AWS +############################ + +# This env var is used to set local AWS credentials +IS_LOCAL_AWS=1 + +############################ +# AWS SES +############################ + +AWS_SES_FROM_EMAIL=noreply@local.test + + +############################ +# DynamoDB Mock +############################ +FILE_SCAN_CACHE_TABLE_NAME=local-virus-scan + +# File scan results streaming endpoint - how long to keep streaming, and how +# long to sleep between DynamoDB polls. +FILE_SCAN_RESULTS_POLL_INTERVAL_SECONDS=3 +FILE_SCAN_RESULTS_MAX_DURATION_SECONDS=60 + +############################ +# S3 +############################ + +# Our terraform sets these as s3 paths, so include s3:// on the bucket name +PUBLIC_FILES_BUCKET=s3://local-mock-public-bucket +DRAFT_FILES_BUCKET=s3://local-mock-draft-bucket +FILE_SCAN_BUCKET=s3://local-mock-file-scan-bucket + +############################ +# AWS API Gateway +############################ + +# Default usage plan ID for newly created API keys +# For local development, use a placeholder value since we mock AWS calls +API_GATEWAY_DEFAULT_USAGE_PLAN_ID=local-dev-usage-plan \ No newline at end of file diff --git a/backend/grants_shared/pyproject.toml b/backend/grants_shared/pyproject.toml new file mode 100644 index 0000000..0b2ba80 --- /dev/null +++ b/backend/grants_shared/pyproject.toml @@ -0,0 +1,235 @@ +[project] +name = "grants-shared" +version = "0.3.0" +description = "Shared code used by the Simpler Grants.gov & Grants Management repos" +readme = "README.md" +license = "CC0-1.0" +authors = [{ name = "Nava Engineering", email = "engineering@navapbc.com" }] +requires-python = ">=3.14,<3.15" +classifiers = [ + # See list of classifiers at https://pypi.org/classifiers + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14" +] + +dependencies = [ + "apiflask>=3.1.0,<4", + "marshmallow>=3.20.1,<4", + "pydantic>=2.13.3,<3", + "pydantic-settings>=2.14.0,<3", + "sqlalchemy[mypy]>=2.0.49,<3", + "psycopg[binary]>=3.3.4,<4", + "botocore>=1.43.3,<2", + "boto3>=1.43.3,<2", + "smart-open>=7.6.0,<8", + "pytz>=2026.2,<2027", + "pyjwt[crypto]>=2.12.1,<3", + "jsonschema[format-nongpl]>=4.26.0,<5", + "jsonpath-ng>=1.8.0,<2", + "jsonref>=1.1.0,<2", + "pandas>=2.0.3,<3", + "pandas-stubs>=2.0.3,<3", + "newrelic>=12.1.0,<13", + "python-dotenv>=1.2.2,<2", + "beautifulsoup4>=4.14.3,<5", +] + +[project.urls] +Homepage = "https://github.com/HHS/grants-shared" +Issues = "https://github.com/HHS/grants-shared/issues" + +[dependency-groups] +dev = [ + "black>=26.3.1,<27", + "isort>=8.0.1,<9", + "moto[s3]>=5.2.0,<6", + "mypy>=1.20.2,<2", + "coverage>=7.13.5,<8", + "faker>=40.15.0,<41", + "factory-boy>=3.3.3,<4", + "bandit>=1.9.4,<2", + "pytest>=9.0.3,<10", + "ruff>=0.15.12,<16", + "freezegun>=1.5.5,<2", + "debugpy>=1.8.20,<2", + "types-requests>=2.33.0.20260503", +] + + +[tool.hatch.build.targets.sdist] +include = ["src"] + +[tool.hatch.build.targets.wheel] +packages = ["src/grants_shared"] + +[tool.black] +line-length = 100 + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 100 + +[tool.ruff] +line-length = 100 +# Some rules are considered preview-only, this allows them +# assuming we enabled them below +preview = true + +target-version = "py314" + +[tool.ruff.lint] +# See: https://docs.astral.sh/ruff/rules/ for all possible rules +select = [ + "B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b + "C", + "E", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w + "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f + "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w + "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf + "PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt + "TID251", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 +] +ignore = [ + # too many leading '#' for block comment, we can format our comments however we want + "E266", + # Ignore line-too-long errors, assume the formatter handles that appropriately + "E501", + # Ignore rules regarding unecessary list / generator usage which complains about [e for e in MyEnum] # + "C4", + # Ignore rule that flags functions with many branches - sometimes we just have a lot of + # business rules that make sense to aggregate in one place. + "C901", + # Ruff suggests not doing .encode("utf-8") and leaving utf-8 (the default out), + # but nothing wrong with being explicit and clear + "UP012", + # Ruff suggests using datetime.UTC over datetime.timezone.utc - but other timezones + # would need to be specified the latter way, seems odd to intentionally be inconsistent + "UP017", + # Ruff suggests using f-strings over .format - this one is a good recommendation + # we just have a few too many to refactor at this time. + "UP031", + # Ruff doesn't like array concatenation like [1, 2, 3] + [4, 5, 6], but it's intuitive where used. + "RUF005", + # Ruff doesn't like str() in f-strings, but instead + # recommends conversion flags (eg. f"{a!r}") which is less well known + "RUF010", + # Ruff thinks our SQLAlchemy models and factories have ClassVars + # but those classes don't use those variables in the problematic way it wants to avoid + "RUF012", + # Ruff wants __all__ sorted, not against it, but we'd want our + # formatter to handle that first to avoid it being tedious work + "RUF022", + # Ruff doesn't like when you use variables with _ prefixes in functions + # saying to avoid shadowing other params to do my_var_, but while + # that follows PEP8 formatting, it's not generally what I've seen + "RUF052", + # Ruff doesn't want any code in __init__ files, but some of our + # libraries and patterns need a very small amount of code configured + # in these files. + "RUF067", +] + +# These are characters we are allowing to be confusing +# for RUF001 which recommends not using certain characters +# that look like each other. +# https://docs.astral.sh/ruff/rules/ambiguous-unicode-character-string/#ambiguous-unicode-character-string-ruf001 +allowed-confusables = [ + # endash (not a regular dash) - we need this in our email formatting + "–", + # right quotation mark (not a simple "'") - used in email formatting + "’" +] + +[tool.ruff.lint.per-file-ignores] +# These are rules that are excluded from just our unit tests +# but still run for the rest of our code. +"tests/*" = [ + # Ruff suggests changing how iterables are merged, but it complicates test setup + "RUF005", + # Ruff wants to avoid "arg: int = None" - we shouldn't do this + # but our tests are only partially typed + "RUF013", + # Ruff suggests making any match statement in pytest.raises + # a proper regex pattern, but we just use it for finding strings in most cases + "RUF043", + # Ruff doesn't like "x, y = get_tuple()" and not using one of the values + # but the value might be used by a developer doing debugging + "RUF059", + # Ruff recommends the first parameter of a parametrized test be a tuple + # We have a lot of tests that don't do that, can circle back to this later + "PT006", + # Ruff recommends having pytest.raises() be a specific error + # but sometimes we just want to verify an error was raised at all + "PT011", + # Ruff recommends not doing "assert x == 1 and y == 2" in tests + # but the few places we do this are kept simple and help better organize complex scenarios + "PT018" +] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +# For these libraries, we do use them, but have our own derived versions we want +# to use instead as we've rewritten fundamental components like error messaging. +"marshmallow.validate".msg = "Do not import marshmallow.validate directly - instead use grants_shared.api.schemas.extension.validators as we've rewritten the error messaging" +"apiflask.validators".msg = "Do not import apiflask.validators directly - instead use grants_shared.api.schemas.extension.validators as we've rewritten the error messaging" +"apiflask.fields".msg = "Do not import apiflask.fields directly - instead use grants_shared.api.schemas.extension.fields as we've rewritten the error messaging" +"apiflask.Schema".msg = "Do not import apiflask.Schema directly - instead use grants_shared.api.schemas.extension" + +[tool.mypy] +# https://mypy.readthedocs.io/en/stable/config_file.html +color_output = true +error_summary = true +pretty = true +show_error_codes = true +show_column_numbers = true +show_error_context = true + +namespace_packages = true +ignore_missing_imports = true +warn_unused_configs = true + +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +strict_equality = true +warn_no_return = true +warn_redundant_casts = true +warn_unreachable = true +warn_unused_ignores = true + +plugins = ["pydantic.mypy"] + +[tool.bandit] +# Ignore audit logging test file since test audit logging requires a lot of operations that trigger bandit warnings +exclude_dirs = ["./tests/grants_shared/logs/test_audit.py"] + + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +markers = [ + "audit: mark a test as a security audit log test, to be run isolated from other tests", +] + +[tool.coverage.run] +omit = [ + # Decodelog is only used for formatting logs locally + "src/grants_shared/logs/decodelog.py" +] + +[tool.coverage.report] +fail_under = 80 + +exclude_lines = [ + # Exclude abstract & overloaad methods from + # code coverage reports as they won't ever directly run + "@abc.abstractmethod", + "@abstractmethod", + "@typing.overload", +] diff --git a/backend/grants_shared/src/__init__.py b/backend/grants_shared/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/__init__.py b/backend/grants_shared/src/grants_shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/__init__.py b/backend/grants_shared/src/grants_shared/adapters/aws/__init__.py new file mode 100644 index 0000000..6bd18c4 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/__init__.py @@ -0,0 +1,20 @@ +from .aws_session import get_aws_config, get_boto_session, is_local_aws +from .dynamodb_adapter import DynamoDBConfig, get_boto_dynamodb_client +from .s3_adapter import S3Config, get_s3_client +from .ses_adapter import SESConfig, get_ses_client, send_email +from .sqs_adapter import SQSConfig, get_boto_sqs_client + +__all__ = [ + "get_aws_config", + "get_boto_session", + "get_s3_client", + "S3Config", + "get_boto_sqs_client", + "is_local_aws", + "get_ses_client", + "send_email", + "SESConfig", + "SQSConfig", + "get_boto_dynamodb_client", + "DynamoDBConfig", +] diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/api_gateway_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/api_gateway_adapter.py new file mode 100644 index 0000000..c211551 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/api_gateway_adapter.py @@ -0,0 +1,165 @@ +import logging +import uuid + +import boto3 +import botocore.client +from botocore.exceptions import ClientError +from pydantic import BaseModel, Field + +from grants_shared.adapters.aws import get_boto_session +from grants_shared.adapters.aws.aws_session import is_local_aws +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class ApiKeyImportResponse(BaseModel): + id: str = Field(alias="id") + name: str = Field(alias="name") + description: str | None = Field(alias="description", default=None) + enabled: bool = Field(alias="enabled", default=True) + stage_keys: list[str] = Field(alias="stageKeys", default_factory=list) + tags: dict[str, str] = Field(alias="tags", default_factory=dict) + + +class ApiGatewayConfig(PydanticBaseEnvConfig): + """Configuration for AWS API Gateway integration""" + + # Usage plan ID for newly created API keys (typically the public usage plan) + default_usage_plan_id: str = Field(alias="API_GATEWAY_DEFAULT_USAGE_PLAN_ID") + + +def get_boto_api_gateway_client(session: boto3.Session | None = None) -> botocore.client.BaseClient: + """Get a boto3 API Gateway client""" + if session is None: + session = get_boto_session() + + return session.client("apigateway") + + +def import_api_key( + api_key: str, + name: str, + description: str | None = None, + enabled: bool = True, + usage_plan_id: str | None = None, + api_gateway_client: botocore.client.BaseClient | None = None, +) -> ApiKeyImportResponse: + """ + Import an API key into AWS API Gateway using CSV format. + + This function uses AWS API Gateway's CSV import functionality to import an API key + and associate it with a usage plan in a single operation, eliminating the need for + separate usage plan association calls. + + Args: + api_key: The API key value to import (must be 20-128 characters) + name: Name for the API key (cannot exceed 1024 characters) + description: Optional description for the API key + enabled: Whether the API key should be enabled (default: True) + usage_plan_id: Optional usage plan ID to associate the key with during import + api_gateway_client: Optional pre-configured API Gateway client + + Returns: + ApiKeyImportResponse with the imported key details + """ + + if is_local_aws(): + return _handle_mock_import_response(api_key, name, description, enabled, usage_plan_id) + + if api_gateway_client is None: + api_gateway_client = get_boto_api_gateway_client() + + # Format the API key data as CSV for import + # AWS API Gateway expects CSV format: name,key,description,enabled,usageplanIds + # Header row is optional but improves readability and maintainability + usage_plan_ids_str = f'"{usage_plan_id}"' if usage_plan_id else "" + header = "name,key,description,enabled,usageplanIds" + data_row = f"{name},{api_key},{description or ''},{'true' if enabled else 'false'},{usage_plan_ids_str}" + csv_data = f"{header}\n{data_row}" + + try: + response = api_gateway_client.import_api_keys( + body=csv_data.encode("utf-8"), format="csv", failOnWarnings=True + ) + except ClientError: + logger.exception("Error importing API key to AWS API Gateway") + raise + except Exception: + logger.exception("Unexpected error importing API key") + raise + + if response.get("warnings"): + logger.warning("API Gateway import warnings", extra={"warnings": response["warnings"]}) + + imported_key_ids = response.get("ids", []) + if not imported_key_ids: + raise Exception("No API key IDs returned from import operation") + + key_id = imported_key_ids[0] + + try: + key_details = api_gateway_client.get_api_key(apiKey=key_id, includeValue=False) + except ClientError: + logger.exception( + "Error retrieving API key details from AWS API Gateway", + extra={"key_id": key_id}, + ) + raise + except Exception as e: + logger.exception( + "Unexpected error retrieving API key details", extra={"error": str(e), "key_id": key_id} + ) + raise + + api_key_response = ApiKeyImportResponse.model_validate(key_details) + + if usage_plan_id: + logger.info( + "API key imported with usage plan association", + extra={"api_key_id": api_key_response.id, "usage_plan_id": usage_plan_id}, + ) + + return api_key_response + + +_mock_import_responses: list[tuple[dict, ApiKeyImportResponse]] = [] + + +def _handle_mock_import_response( + api_key: str, name: str, description: str | None, enabled: bool, usage_plan_id: str | None +) -> ApiKeyImportResponse: + response = ApiKeyImportResponse( + id=f"mock-{str(uuid.uuid4())[:8]}", + name=name, + description=description, + enabled=enabled, + stageKeys=[], + tags={}, + ) + + global _mock_import_responses + request_data = { + "api_key": api_key, + "name": name, + "description": description, + "enabled": enabled, + "usage_plan_id": usage_plan_id, + } + _mock_import_responses.append((request_data, response)) + + logger.info( + "Mock API key import", + extra={"mock_key_id": response.id, "key_name": name, "usage_plan_id": usage_plan_id}, + ) + + return response + + +def _clear_mock_import_responses() -> None: + global _mock_import_responses + _mock_import_responses = [] + + +def _get_mock_import_responses() -> list[tuple[dict, ApiKeyImportResponse]]: + return _mock_import_responses diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/aws_session.py b/backend/grants_shared/src/grants_shared/adapters/aws/aws_session.py new file mode 100644 index 0000000..a8f1ea7 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/aws_session.py @@ -0,0 +1,38 @@ +import boto3 +from pydantic import Field + +from grants_shared.util.env_config import PydanticBaseEnvConfig + + +class AwsConfig(PydanticBaseEnvConfig): + is_local_aws: bool = False + aws_region: str = Field(alias="AWS_REGION", default="us-east-1") + + +_aws_config: AwsConfig | None = None + + +def get_aws_config() -> AwsConfig: + global _aws_config + if _aws_config is None: + _aws_config = AwsConfig() + + return _aws_config + + +def is_local_aws() -> bool: + """Whether we are running against local AWS which affects the credentials we use (forces them to be not real)""" + return get_aws_config().is_local_aws + + +def get_boto_session() -> boto3.Session: + config = get_aws_config() + if is_local_aws(): + # Locally, set fake creds so we can't hit actual AWS resources + return boto3.Session( + aws_access_key_id="NO_CREDS", + aws_secret_access_key="NO_CREDS", + region_name=config.aws_region, + ) + + return boto3.Session(region_name=config.aws_region) diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/dynamodb_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/dynamodb_adapter.py new file mode 100644 index 0000000..7eb3846 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/dynamodb_adapter.py @@ -0,0 +1,164 @@ +import logging +from typing import Any + +import boto3 +import botocore.client +from pydantic import BaseModel, Field + +from grants_shared.adapters.aws import get_aws_config, get_boto_session +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class DynamoDBConfig(PydanticBaseEnvConfig): + aws_dynamodb_endpoint_url: str | None = Field(alias="AWS_DYNAMODB_ENDPOINT_URL", default=None) + file_scan_cache_table_name: str = Field(alias="FILE_SCAN_CACHE_TABLE_NAME") + + +class DynamoDBGetItemResponse(BaseModel): + """Represents the response from getting an item from DynamoDB.""" + + item: dict[str, Any] | None = Field(default=None) + + +def get_boto_dynamodb_client( + dynamodb_config: DynamoDBConfig | None = None, session: boto3.Session | None = None +) -> botocore.client.BaseClient: + if dynamodb_config is None: + dynamodb_config = DynamoDBConfig() + + params: dict[str, Any] = {} + if dynamodb_config.aws_dynamodb_endpoint_url is not None: + params["endpoint_url"] = dynamodb_config.aws_dynamodb_endpoint_url + # dynamodb-local rejects the "NO_CREDS" access key the default boto + # session uses for fake-AWS; pass the same value setup_local_dynamodb + # uses so reads and writes both work against the local container. + params["aws_access_key_id"] = "local" + params["aws_secret_access_key"] = "local" + + if session is None: + session = get_boto_session() + + return session.client("dynamodb", region_name=get_aws_config().aws_region, **params) + + +class DynamoDBClient: + def __init__(self, dynamodb_client: botocore.client.BaseClient | None = None): + self.client = dynamodb_client or get_boto_dynamodb_client() + + def get_item( + self, + table_name: str, + key_name: str, + value: Any, + key_type: str = "S", + consistent_read: bool = True, + ) -> DynamoDBGetItemResponse: + """ + Get an item from DynamoDB by its key. + + Args: + table_name: The name of the DynamoDB table + key_name: The name of the key attribute + value: The value of the key + key_type: DynamoDB type - "S" (string), "N" (number), "B" (binary). Default: "S" + consistent_read: Whether to use consistent read (default: True) + + Returns: + DynamoDBGetItemResponse containing the item if found, None otherwise + + Examples: + # String key (most common) + response = client.get_item( + table_name="virus-scan-cache", + key_name="file_id", + value="abc-123" + ) + + # Number key + response = client.get_item( + table_name="user-table", + key_name="user_id", + value="12345", + key_type="N" + ) + + # Eventually consistent read + response = client.get_item( + table_name="my-table", + key_name="id", + value="test-id", + consistent_read=False + ) + """ + key = {key_name: {key_type: value}} + log_extra = { + "table_name": table_name, + "key_name": key_name, + "key_type": key_type, + "value": value, + "consistent_read": consistent_read, + } + + try: + logger.info( + "Getting item from DynamoDB", + extra=log_extra, + ) + + response = self.client.get_item( + TableName=table_name, + Key=key, + ConsistentRead=consistent_read, + ) + + item = response.get("Item") + + if item: + logger.info( + "Successfully retrieved item from DynamoDB", + extra=log_extra, + ) + else: + logger.info( + "Item not found in DynamoDB", + extra=log_extra, + ) + + return DynamoDBGetItemResponse(item=item) + + except Exception: + logger.exception( + "Failed to get item from DynamoDB", + extra=log_extra, + ) + raise + + def put_item( + self, + table_name: str, + item: dict[str, Any], + ) -> None: + """ + Write an item to DynamoDB. PutItem is unconditional, so this both + creates new rows and replaces existing ones. + + Args: + table_name: The name of the DynamoDB table + item: The full item in DynamoDB attribute-value format + (e.g., {"file_id": {"S": "abc-123"}, "status": {"S": "complete"}}) + + Examples: + client.put_item( + table_name="virus-scan-cache", + item={ + "file_id": {"S": "abc-123"}, + "user_id": {"S": "user-1"}, + "status": {"S": "complete"}, + }, + ) + """ + log_extra = {"table_name": table_name, "key_attribute_value": item.get("file_id")} + logger.info("Putting item into DynamoDB", extra=log_extra) + self.client.put_item(TableName=table_name, Item=item) diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/s3_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/s3_adapter.py new file mode 100644 index 0000000..e2b5b65 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/s3_adapter.py @@ -0,0 +1,59 @@ +import boto3 +import botocore.client +import botocore.config +from pydantic import Field + +from grants_shared.adapters.aws import get_aws_config, get_boto_session +from grants_shared.util.env_config import PydanticBaseEnvConfig + + +class S3Config(PydanticBaseEnvConfig): + # We should generally not need to set this except + # locally to use s3mock + aws_s3_endpoint_url: str | None = Field(alias="AWS_S3_ENDPOINT_URL", default=None) + presigned_s3_duration: int = 900 # 15 minutes in seconds + + # CDN URL for public files - if set, will be used instead of presigned URLs + cdn_url: str | None = None + + ### S3 Buckets + # note that we default these to None + # so that we don't need to set all of these for every + # process that uses S3 + + # Note these env vars get set as "s3://..." + public_files_bucket_path: str = Field(alias="PUBLIC_FILES_BUCKET") + draft_files_bucket_path: str = Field(alias="DRAFT_FILES_BUCKET") + file_scan_bucket_path: str = Field(alias="FILE_SCAN_BUCKET") + + +def get_s3_client( + s3_config: S3Config | None = None, + session: boto3.Session | None = None, + boto_config: botocore.config.Config | None = None, +) -> botocore.client.BaseClient: + if s3_config is None: + s3_config = S3Config() + + params = {} + if s3_config.aws_s3_endpoint_url is not None: + params["endpoint_url"] = s3_config.aws_s3_endpoint_url + + if boto_config is None: + boto_config = botocore.config.Config( + signature_version="s3v4", + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + # Force path-style addressing (s3..amazonaws.com/). + # Virtual-hosted-style requests (.s3.amazonaws.com) fail with + # HTTP 500 from inside our VPC, which surfaces as smart_open's + # "bucket does not exist, or is forbidden for access" on multipart uploads. + s3={"addressing_style": "path"}, + ) + + params["config"] = boto_config + + if session is None: + session = get_boto_session() + + return session.client("s3", region_name=get_aws_config().aws_region, **params) diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/ses_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/ses_adapter.py new file mode 100644 index 0000000..8d8d431 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/ses_adapter.py @@ -0,0 +1,98 @@ +import logging + +import boto3 +import botocore.client +from botocore.exceptions import ClientError +from pydantic import BaseModel, Field + +from grants_shared.adapters.aws.aws_session import get_boto_session, is_local_aws +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class SESConfig(PydanticBaseEnvConfig): + aws_ses_from_email: str = Field(alias="AWS_SES_FROM_EMAIL") + + +class SESResponse(BaseModel): + message_id: str = Field(alias="MessageId") + + +def get_ses_client(session: boto3.Session | None = None) -> botocore.client.BaseClient: + if session is None: + session = get_boto_session() + + return session.client("sesv2") + + +def send_email( + to_address: str, + subject: str, + message: str, + ses_client: botocore.client.BaseClient | None = None, +) -> str: + """ + Send an email using AWS SESv2. + + Args: + to_address: Email address to send to + subject: Email subject line + message: Email body (supports both HTML and plain text) + ses_client: Optional SESv2 client (for testing) + + Returns: + Message ID from SESv2 + + Raises: + Exception: If email fails to send + """ + config = SESConfig() + + if is_local_aws(): + # the local logger has the email details for debugging + logger.info( + "Local environment detected - not sending actual email", + extra={ + "to_address": to_address, + "subject": subject, + "from_address": config.aws_ses_from_email, + }, + ) + return "local-mock-message-id" + + if ses_client is None: + ses_client = get_ses_client() + + try: + logger.info("Sending email via SESv2") + + response = ses_client.send_email( + FromEmailAddress=config.aws_ses_from_email, + Destination={"ToAddresses": [to_address]}, + Content={ + "Simple": { + "Subject": {"Data": subject, "Charset": "UTF-8"}, + "Body": { + "Text": {"Data": message, "Charset": "UTF-8"}, + "Html": {"Data": message, "Charset": "UTF-8"}, + }, + } + }, + ) + + response_object = SESResponse.model_validate(response) + message_id = response_object.message_id + + logger.info( + "Successfully sent email via SESv2", + extra={ + "message_id": message_id, + }, + ) + + return message_id + + except ClientError: + logger.exception("Failed to send email via SESv2") + raise diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/ses_suppressed_email_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/ses_suppressed_email_adapter.py new file mode 100644 index 0000000..2b41368 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/ses_suppressed_email_adapter.py @@ -0,0 +1,120 @@ +import logging +from abc import ABC, ABCMeta, abstractmethod +from datetime import datetime + +import boto3 +import botocore.client +from botocore.exceptions import ClientError +from pydantic import BaseModel, Field + +from grants_shared.adapters.aws import get_boto_session +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class SuppressedDestination(BaseModel): + email_address: str = Field(alias="EmailAddress") + reason: str = Field(alias="Reason") + last_update_time: datetime = Field(alias="LastUpdateTime") + + +class SESV2Response(BaseModel): + suppressed_destination_summaries: list[SuppressedDestination] = Field( + alias="SuppressedDestinationSummaries", default_factory=list + ) + next_token: str | None = Field(alias="NextToken", default=None) + + +class BaseSESV2Client(ABC, metaclass=ABCMeta): + @abstractmethod + def list_suppressed_destinations(self, start_time: datetime | None = None) -> SESV2Response: + pass + + +class SESV2Client(BaseSESV2Client): + def __init__(self) -> None: + self.client = get_boto_sesv2_client() + + def list_suppressed_destinations(self, start_time: datetime | None = None) -> SESV2Response: + request_params: dict[str, datetime | str] = {} + if start_time: + request_params["StartDate"] = start_time + + all_summaries: list[SuppressedDestination] = [] + next_token = None + + try: + logger.info("Retrieving suppressed destinations") + iterations = 0 + while True: + iterations += 1 + + if next_token: + request_params["NextToken"] = next_token + + response = self.client.list_suppressed_destinations(**request_params) + logger.info( + "Raw count of suppressed emails returned: %d", + len(response.get("SuppressedDestinationSummaries", [])), + ) + + response_object = SESV2Response.model_validate(response) + all_summaries.extend(response_object.suppressed_destination_summaries) + + next_token = response_object.next_token + if not next_token: + break + if iterations > 100: + logger.error( + "Stopping iteration of response from list suppression API after 100 iterations" + ) + break + + except ClientError: + logger.exception("Error calling list_suppressed_destinations") + raise + + return SESV2Response(SuppressedDestinationSummaries=all_summaries) + + +class MockSESV2Client(BaseSESV2Client): + def __init__(self, page_size: int = 1) -> None: + self.mock_responses: list[SuppressedDestination] = [] + + def add_mock_responses(self, response: SuppressedDestination) -> None: + """Seed mock responses list with test data.""" + self.mock_responses.append(response) + + def list_suppressed_destinations( + self, + start_time: datetime | None = None, + next_token: str | None = None, + ) -> SESV2Response: + """Return suppressed destination with optional time filter.""" + results = self.mock_responses + if start_time: + results = [r for r in results if r.last_update_time >= start_time] + + return SESV2Response( + SuppressedDestinationSummaries=[r.model_dump(by_alias=True) for r in results] + ) + + +class SesConfig(PydanticBaseEnvConfig): + use_mock_ses_client: bool = False + + +def get_sesv2_client() -> BaseSESV2Client: + config = SesConfig() + if config.use_mock_ses_client: + return MockSESV2Client() + else: + return SESV2Client() + + +def get_boto_sesv2_client(session: boto3.Session | None = None) -> botocore.client.BaseClient: + if session is None: + session = get_boto_session() + + return session.client("sesv2") diff --git a/backend/grants_shared/src/grants_shared/adapters/aws/sqs_adapter.py b/backend/grants_shared/src/grants_shared/adapters/aws/sqs_adapter.py new file mode 100644 index 0000000..ed7a55e --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/aws/sqs_adapter.py @@ -0,0 +1,152 @@ +import json +import logging + +import boto3 +import botocore.client +from pydantic import BaseModel, Field + +from grants_shared.adapters.aws import get_aws_config, get_boto_session +from grants_shared.util.env_config import PydanticBaseEnvConfig +from grants_shared.util.json_util import json_encoder + +logger = logging.getLogger(__name__) + + +class SQSConfig(PydanticBaseEnvConfig): + workflow_queue_url: str = Field(alias="WORKFLOW_QUEUE_URL") + aws_sqs_endpoint_url: str | None = Field(alias="AWS_SQS_ENDPOINT_URL", default=None) + + +class SQSMessage(BaseModel): + """Represents a simplified SQS message object.""" + + body: str = Field(alias="Body") + receipt_handle: str = Field(alias="ReceiptHandle") + message_id: str = Field(alias="MessageId") + attributes: dict[str, str] = Field(alias="Attributes", default_factory=dict) + + +class SQSDeleteBatchResponse(BaseModel): + """Represents the results of an SQS batch delete operation.""" + + successful_deletes: set[str] = Field(default_factory=set) + failed_deletes: set[str] = Field(default_factory=set) + + +class SQSSendMessageResponse(BaseModel): + """Represents the response from sending a message to SQS.""" + + message_id: str = Field(alias="MessageId") + md5_of_message_body: str = Field(alias="MD5OfMessageBody") + md5_of_message_attributes: str | None = Field(alias="MD5OfMessageAttributes", default=None) + sequence_number: str | None = Field(alias="SequenceNumber", default=None) + md5_of_message_system_attributes: str | None = Field( + alias="MD5OfMessageSystemAttributes", default=None + ) + + +def get_boto_sqs_client( + sqs_config: SQSConfig | None = None, session: boto3.Session | None = None +) -> botocore.client.BaseClient: + if sqs_config is None: + sqs_config = SQSConfig() + + params = {} + if sqs_config.aws_sqs_endpoint_url is not None: + params["endpoint_url"] = sqs_config.aws_sqs_endpoint_url + + if session is None: + session = get_boto_session() + + return session.client("sqs", region_name=get_aws_config().aws_region, **params) + + +class SQSClient: + def __init__(self, queue_url: str, sqs_client: botocore.client.BaseClient | None = None): + self.queue_url = queue_url + self.client = sqs_client or get_boto_sqs_client() + + def receive_messages( + self, max_messages: int = 10, wait_time: int = 10, visibility_timeout: int = 300 + ) -> list[SQSMessage]: + """Fetch messages from SQS using long polling and return as SQSMessage objects.""" + try: + logger.info("Fetching messages from SQS", extra={"queue_url": self.queue_url}) + response = self.client.receive_message( + QueueUrl=self.queue_url, + MaxNumberOfMessages=max_messages, + WaitTimeSeconds=wait_time, + VisibilityTimeout=visibility_timeout, + AttributeNames=["All"], + MessageAttributeNames=["All"], + ) + + raw_messages = response.get("Messages", []) + + return [SQSMessage.model_validate(m) for m in raw_messages] + + except Exception: + logger.exception( + "Failed to receive messages from SQS", extra={"queue_url": self.queue_url} + ) + raise + + def delete_message_batch(self, receipt_handles: list[str]) -> SQSDeleteBatchResponse: + """ + Deletes a batch of messages and returns an SQSDeleteBatchResponse. + """ + if not receipt_handles: + logger.info("No SQS messages to delete", extra={"queue_url": self.queue_url}) + return SQSDeleteBatchResponse() + + receipt_mapping = {} + entries = [] + for i, handle in enumerate(receipt_handles): + id_str = str(i) + receipt_mapping[id_str] = handle + entries.append({"Id": id_str, "ReceiptHandle": handle}) + + try: + logger.info( + "Deleting messages from SQS", + extra={"queue_url": self.queue_url, "receipt_handles": ",".join(receipt_handles)}, + ) + response = self.client.delete_message_batch(QueueUrl=self.queue_url, Entries=entries) + + batch_results = SQSDeleteBatchResponse() + + for success in response.get("Successful", []): + batch_results.successful_deletes.add(receipt_mapping[success["Id"]]) + + for failure in response.get("Failed", []): + batch_results.failed_deletes.add(receipt_mapping[failure["Id"]]) + + return batch_results + + except Exception: + logger.exception("Failed to delete message batch", extra={"queue_url": self.queue_url}) + raise + + def send_message(self, message_body: dict) -> SQSSendMessageResponse: + """ + Sends a message to the SQS queue and returns the response. + """ + try: + logger.info("Sending message to SQS", extra={"queue_url": self.queue_url}) + # To handle converting common types like uuids, we use our json_encoder + message_body_str = json.dumps(message_body, default=json_encoder) + + response = self.client.send_message( + QueueUrl=self.queue_url, MessageBody=message_body_str + ) + + logger.info( + "Successfully sent message to SQS", + extra={"queue_url": self.queue_url, "message_id": response.get("MessageId")}, + ) + + return SQSSendMessageResponse.model_validate(response) + + except Exception: + logger.exception("Failed to send message to SQS", extra={"queue_url": self.queue_url}) + raise diff --git a/backend/grants_shared/src/grants_shared/adapters/db/__init__.py b/backend/grants_shared/src/grants_shared/adapters/db/__init__.py new file mode 100644 index 0000000..db18eac --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/__init__.py @@ -0,0 +1,33 @@ +""" +Database module. + +This module contains the DBClient class, which is used to manage database connections. +This module can be used on it's own or with an application framework such as Flask. + +To use this module with Flask, use the flask_db module. + +Usage: + import grants_shared.adapters.db as db + + db_client = db.PostgresDBClient() + + # non-ORM style usage + with db_client.get_connection() as conn: + conn.execute(...) + + # ORM style usage + with db_client.get_session() as session: + session.query(...) + with session.begin(): + session.add(...) +""" + +# Re-export for convenience +from grants_shared.adapters.db.client import Connection, DBClient, Session +from grants_shared.adapters.db.clients.postgres_client import PostgresDBClient +from grants_shared.adapters.db.clients.postgres_config import PostgresDBConfig + +# Do not import flask_db here, because this module is not dependent on any specific framework. +# Code can choose to use this module on its own or with the flask_db module depending on needs. + +__all__ = ["Connection", "DBClient", "Session", "PostgresDBClient", "PostgresDBConfig"] diff --git a/backend/grants_shared/src/grants_shared/adapters/db/client.py b/backend/grants_shared/src/grants_shared/adapters/db/client.py new file mode 100644 index 0000000..ed59273 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/client.py @@ -0,0 +1,66 @@ +""" +This module contains the DBClient class, which is used to manage database connections + +For usage information look at the package docstring in __init__.py +""" + +import abc +import logging + +import sqlalchemy +from sqlalchemy.orm import session + +# Re-export the Connection type that is returned by the get_connection() method +# to be used for type hints. +Connection = sqlalchemy.engine.Connection + +# Re-export the Session type that is returned by the get_session() method +# to be used for type hints. +Session = session.Session + +logger = logging.getLogger(__name__) + + +class DBClient(abc.ABC, metaclass=abc.ABCMeta): + """Database connection manager. + + This class is used to manage database connections for the Flask app. + It has methods for getting a new connection or session object. + + A derived class must initialize _engine in the __init__ function + """ + + _engine: sqlalchemy.engine.Engine + + @abc.abstractmethod + def check_db_connection(self) -> None: + raise NotImplementedError() + + def get_connection(self) -> Connection: + """Return a new database connection object. + + Use the connection to execute SQL queries without using the ORM. + + Usage: + with db.get_connection() as conn: + conn.execute(...) + """ + return self._engine.connect() + + def get_session(self) -> Session: + """Return a new session object. + + In general, only one session object should be created per request. + + If you want to automatically commit or rollback the session, use + the session.begin() context manager. + See https://docs.sqlalchemy.org/en/13/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it + + Example: + with db.get_session() as session: + with session.begin(): + session.add(...) + # session is automatically committed here + # or rolled back if an exception is raised + """ + return Session(bind=self._engine, expire_on_commit=False, autocommit=False) diff --git a/backend/grants_shared/src/grants_shared/adapters/db/clients/__init__.py b/backend/grants_shared/src/grants_shared/adapters/db/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_client.py b/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_client.py new file mode 100644 index 0000000..a416139 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_client.py @@ -0,0 +1,122 @@ +import logging +from typing import Any + +import boto3 +import psycopg +import sqlalchemy +import sqlalchemy.pool as pool + +from grants_shared.adapters.db.client import DBClient +from grants_shared.adapters.db.clients.postgres_config import PostgresDBConfig, get_db_config + +logger = logging.getLogger(__name__) + + +class PostgresDBClient(DBClient): + """ + An implementation of a DBClient for connecting to a Postgres DB + as configured by parameters passed in from the db_config + """ + + def __init__(self, db_config: PostgresDBConfig | None = None) -> None: + if not db_config: + db_config = get_db_config() + self._engine = self._configure_engine(db_config) + + if db_config.check_connection_on_init: + self.check_db_connection() + + def _configure_engine(self, db_config: PostgresDBConfig) -> sqlalchemy.engine.Engine: + # We want to be able to control the connection parameters for each + # connection because for IAM authentication with RDS, short-lived tokens are + # used as the password, and so we potentially need to generate a fresh token + # for each connection. + # + # For more details on building connection pools, see the docs: + # https://docs.sqlalchemy.org/en/13/core/pooling.html#constructing-a-pool + def get_conn() -> Any: + return psycopg.connect(**get_connection_parameters(db_config)) + + conn_pool = pool.QueuePool(get_conn, max_overflow=10, pool_size=20) + + # The URL only needs to specify the dialect, since the connection pool + # handles the actual connections. + # + # (a SQLAlchemy Engine represents a Dialect+Pool) + return sqlalchemy.create_engine( + "postgresql+psycopg://", + pool=conn_pool, + hide_parameters=db_config.hide_sql_parameter_logs, + execution_options={"schema_translate_map": db_config.get_schema_translate_map()}, + # Don't think we need this as we aren't using JSON columns, but keeping for reference + # json_serializer=lambda o: json.dumps(o, default=pydantic.json.pydantic_encoder), + ) + + def check_db_connection(self) -> None: + with self.get_connection() as conn: + conn_info = conn.connection.dbapi_connection.info + + logger.info( + "connected to postgres db", + extra={ + "dbname": conn_info.dbname, + "user": conn_info.user, + "host": conn_info.host, + "port": conn_info.port, + "options": conn_info.options, + "dsn_parameters": conn_info.dsn, + "protocol_version": conn_info.pgconn.protocol_version, + "server_version": conn_info.server_version, + }, + ) + verify_ssl(conn_info) + + +def get_connection_parameters(db_config: PostgresDBConfig) -> dict[str, Any]: + connect_args: dict[str, Any] = {} + + if db_config.password is None: + assert ( + db_config.aws_region is not None + ), "AWS region needs to be configured for DB IAM auth if DB password is not configured" + password = generate_iam_auth_token( + db_config.aws_region, db_config.host, db_config.port, db_config.username + ) + else: + password = db_config.password + + return dict( + host=db_config.host, + dbname=db_config.name, + user=db_config.username, + password=password, + port=db_config.port, + connect_timeout=10, + sslmode=db_config.ssl_mode, + **connect_args, + ) + + +def generate_iam_auth_token(aws_region: str, host: str, port: int, user: str) -> str: + logger.info( + "generating db iam auth token", + extra={ + "aws_region": aws_region, + "user": user, + "host": host, + "port": port, + }, + ) + client = boto3.client("rds", region_name=aws_region) + token = client.generate_db_auth_token( + DBHostname=host, Port=port, DBUsername=user, Region=aws_region + ) + return token + + +def verify_ssl(connection_info: Any) -> None: + """Verify that the database connection is encrypted and log a warning if not.""" + if connection_info.pgconn.ssl_in_use: + logger.info("database connection is using SSL") + else: + logger.warning("database connection is not using SSL") diff --git a/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_config.py b/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_config.py new file mode 100644 index 0000000..580df6a --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/clients/postgres_config.py @@ -0,0 +1,50 @@ +import logging + +from pydantic import Field + +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class PostgresDBConfig(PydanticBaseEnvConfig): + check_connection_on_init: bool = Field(True, alias="DB_CHECK_CONNECTION_ON_INIT") + aws_region: str | None = Field(None, alias="AWS_REGION") + host: str = Field(alias="DB_HOST") + name: str = Field(alias="DB_NAME") + username: str = Field(alias="DB_USER") + password: str | None = Field(None, alias="DB_PASSWORD") + port: int = Field(5432, alias="DB_PORT") + hide_sql_parameter_logs: bool = Field(True, alias="HIDE_SQL_PARAMETER_LOGS") + ssl_mode: str = Field("require", alias="DB_SSL_MODE") + + all_db_schemas: str = Field(alias="ALL_DB_SCHEMAS") + + schema_prefix_override: str | None = Field(None) + + def get_schema_translate_map(self) -> dict[str, str]: + prefix = "" + if self.schema_prefix_override is not None: + prefix = self.schema_prefix_override + + schemas = self.all_db_schemas.split(",") + + return {schema: f"{prefix}{schema}" for schema in schemas} + + +def get_db_config() -> PostgresDBConfig: + db_config = PostgresDBConfig() + + logger.info( + "Constructed database configuration", + extra={ + "host": db_config.host, + "dbname": db_config.name, + "username": db_config.username, + "password": "***" if db_config.password is not None else None, + "port": db_config.port, + "hide_sql_parameter_logs": db_config.hide_sql_parameter_logs, + }, + ) + + return db_config diff --git a/backend/grants_shared/src/grants_shared/adapters/db/flask_db.py b/backend/grants_shared/src/grants_shared/adapters/db/flask_db.py new file mode 100644 index 0000000..dae6a1a --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/flask_db.py @@ -0,0 +1,126 @@ +""" +This module has functionality to extend Flask with a database client. + +To initialize this flask extension, call register_db_client() with an instance +of a Flask app and an instance of a DBClient. + +Example: + import grants_shared.adapters.db as db + import grants_shared.adapters.db.flask_db as flask_db + + db_client = db.PostgresDBClient() + app = APIFlask(__name__) + flask_db.register_db_client(db_client, app) + +Then, in a request handler, use the with_db_session decorator to get a +new database session that lasts for the duration of the request. + +Example: + import grants_shared.adapters.db as db + import grants_shared.adapters.db.flask_db as flask_db + + @app.route("/health") + @flask_db.with_db_session + def health(db_session: db.Session): + with db_session.begin(): + ... + + +Alternatively, if you want to get the database client directly, use the get_db +function. + +Example: + from flask import current_app + import grants_shared.adapters.db.flask_db as flask_db + + @app.route("/health") + def health(): + db_client = flask_db.get_db(current_app) + # db_client.get_connection() or db_client.get_session() +""" + +from collections.abc import Callable +from functools import wraps +from typing import Concatenate, ParamSpec, TypeVar + +from flask import Flask, current_app + +import grants_shared.adapters.db as db +from grants_shared.adapters.db.client import DBClient + +_FLASK_EXTENSION_KEY_PREFIX = "db" +_DEFAULT_CLIENT_NAME = "default" + + +def register_db_client( + db_client: DBClient, app: Flask, client_name: str = _DEFAULT_CLIENT_NAME +) -> None: + """Initialize the Flask app. + + Add the database to the Flask app's extensions so that it can be + accessed by request handlers using the current app context. + + If you use multiple DB clients, you can differentiate them by + specifying a client_name. + + see get_db + """ + flask_extension_key = f"{_FLASK_EXTENSION_KEY_PREFIX}{client_name}" + app.extensions[flask_extension_key] = db_client + + +def get_db(app: Flask, client_name: str = _DEFAULT_CLIENT_NAME) -> DBClient: + """Get the database connection for the given Flask app. + + Use this in request handlers to access the database from the active Flask app. + + Specify the same client_name as used in register_db_client to get the correct client + + Example: + from flask import current_app + import grants_shared.adapters.db.flask_db as flask_db + + @app.route("/health") + def health(): + db_client = flask_db.get_db(current_app) + """ + flask_extension_key = f"{_FLASK_EXTENSION_KEY_PREFIX}{client_name}" + return app.extensions[flask_extension_key] + + +P = ParamSpec("P") +T = TypeVar("T") + + +def with_db_session( + *, client_name: str = _DEFAULT_CLIENT_NAME +) -> Callable[[Callable[Concatenate[db.Session, P], T]], Callable[P, T]]: + """Decorator for functions that need a database session. + + This decorator will create a new session object and pass it to the function + as the first positional argument. A transaction is not started automatically. + To start a transaction use db_session.begin() + + Usage: + @with_db_session() + def foo(db_session: db.Session): + ... + + @with_db_session() + def bar(db_session: db.Session, x, y): + ... + + @with_db_session(client_name="legacy_db") + def fiz(db_session: db.Session, x, y, z): + ... + """ + + def decorator(f: Callable[Concatenate[db.Session, P], T]) -> Callable[P, T]: + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + with get_db(current_app, client_name=client_name).get_session() as session: + return f(session, *args, **kwargs) + + return wrapper + + return decorator diff --git a/backend/grants_shared/src/grants_shared/adapters/db/type_decorators/__init__.py b/backend/grants_shared/src/grants_shared/adapters/db/type_decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/adapters/db/type_decorators/postgres_type_decorators.py b/backend/grants_shared/src/grants_shared/adapters/db/type_decorators/postgres_type_decorators.py new file mode 100644 index 0000000..c6e89bd --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/db/type_decorators/postgres_type_decorators.py @@ -0,0 +1,47 @@ +from typing import Any + +from sqlalchemy import Integer +from sqlalchemy.types import TypeDecorator + +from grants_shared.db.models.lookup import LookupRegistry, LookupTable + + +class LookupColumn(TypeDecorator): + """ + A Postgres column decorator that wraps + an integer column representing a lookup int. + + This takes in the LookupTable that the lookup value + is stored in, and handles converting the Lookup object + in-code into the integer in the DB automatically (and the reverse). + """ + + impl = Integer + + cache_ok = True + + def __init__(self, lookup_table: type[LookupTable], *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.lookup_table = lookup_table + + def process_bind_param(self, value: Any | None, dialect: Any) -> int | None: + if value is None: + return None + + if not LookupRegistry.is_valid_type_for_table(self.lookup_table, value): + raise Exception( + f"Cannot convert value of type {type(value)} for binding column in table {self.lookup_table.get_table_name()}" + ) + + return LookupRegistry.get_lookup_int_for_enum(self.lookup_table, value) + + def process_result_value(self, value: Any | None, dialect: Any) -> Any | None: + if value is None: + return None + + if not isinstance(value, int): + raise Exception( + f"Cannot process value from DB of type {type(value)} in table {self.lookup_table.get_table_name()}" + ) + + return LookupRegistry.get_enum_for_lookup_int(self.lookup_table, value) diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/__init__.py b/backend/grants_shared/src/grants_shared/adapters/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/__init__.py b/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/login_gov_oauth_client.py b/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/login_gov_oauth_client.py new file mode 100644 index 0000000..54bff19 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/login_gov_oauth_client.py @@ -0,0 +1,49 @@ +from typing import Any + +import requests + +from grants_shared.adapters.oauth.oauth_client import BaseOauthClient +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenRequest, OauthTokenResponse +from grants_shared.auth.login_gov_jwt_auth import LoginGovConfig, get_config + + +class LoginGovOauthClient(BaseOauthClient): + + def __init__(self, config: LoginGovConfig | None = None): + if config is None: + config = get_config() + + self.config = config + self.session = self._build_session() + + def _build_session(self, session: requests.Session | None = None) -> requests.Session: + """Set things on the session that should be shared between all requests""" + if not session: + session = requests.Session() + + session.headers.update({"Content-Type": "application/x-www-form-urlencoded"}) + + return session + + def _request(self, method: str, full_url: str, **kwargs: Any) -> requests.Response: + """Utility method for making a request with our session""" + + # By default timeout after 5 seconds + if "timeout" not in kwargs: + kwargs["timeout"] = 5 + + return self.session.request(method, full_url, **kwargs) + + def get_token(self, request: OauthTokenRequest) -> OauthTokenResponse: + """Query the login.gov token endpoint""" + + body = { + "code": request.code, + "grant_type": request.grant_type, + "client_assertion": request.client_assertion, + "client_assertion_type": request.client_assertion_type, + } + + response = self._request("POST", self.config.login_gov_token_endpoint, data=body) + + return OauthTokenResponse.model_validate_json(response.text) diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/mock_login_gov_oauth_client.py b/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/mock_login_gov_oauth_client.py new file mode 100644 index 0000000..908fca6 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/oauth/login_gov/mock_login_gov_oauth_client.py @@ -0,0 +1,36 @@ +from grants_shared.adapters.oauth.oauth_client import BaseOauthClient +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenRequest, OauthTokenResponse + + +class MockLoginGovOauthClient(BaseOauthClient): + + def __init__(self) -> None: + self.responses: dict[str, OauthTokenResponse] = {} + + # Used to control testing of retry behavior for Login.gov token lookup calls + self.retries: dict[str, int] = {} + + def add_token_response(self, code: str, response: OauthTokenResponse, retries: int = 0) -> None: + self.responses[code] = response + self.retries[code] = retries + + def get_token(self, request: OauthTokenRequest) -> OauthTokenResponse: + retries = self.retries.get(request.code, 0) + # if we don't have retries enabled on the mock, behave as usual + + self.retries[request.code] = retries - 1 + # retries would be one the last time through, as we've reduced it to zero but retries accounts for the data before that + if retries <= 1: + response = self.responses.get(request.code, None) + + if response is None: + response = OauthTokenResponse( + error="error", error_description="default mock error description" + ) + + return response + + # if we did turn on retries on the mock, do retry stuff + return OauthTokenResponse( + error="error", error_description="mock oauth token error description" + ) diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client.py b/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client.py new file mode 100644 index 0000000..8baa9d3 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client.py @@ -0,0 +1,14 @@ +import abc + +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenRequest, OauthTokenResponse + + +class BaseOauthClient(abc.ABC, metaclass=abc.ABCMeta): + + @abc.abstractmethod + def get_token(self, request: OauthTokenRequest) -> OauthTokenResponse: + """Call the POST token endpoint + + See: https://developers.login.gov/oidc/token/ + """ + pass diff --git a/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client_models.py b/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client_models.py new file mode 100644 index 0000000..f55de23 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/adapters/oauth/oauth_client_models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +from pydantic import BaseModel + + +@dataclass +class OauthTokenRequest: + """https://developers.login.gov/oidc/token/#request-parameters""" + + code: str + client_assertion: str + + grant_type: str = "authorization_code" + client_assertion_type: str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +class OauthTokenResponse(BaseModel): + """https://developers.login.gov/oidc/token/#token-response""" + + # These fields are given defaults so we don't need None-checks + # for them elsewhere, if the response didn't error, they have valid values + id_token: str = "" + access_token: str = "" + token_type: str = "" + expires_in: int = 0 + + # These fields are only set if the response errored + error: str | None = None + error_description: str | None = None + + def is_error_response(self) -> bool: + return self.error is not None diff --git a/backend/grants_shared/src/grants_shared/api/__init__.py b/backend/grants_shared/src/grants_shared/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/api/maintenance_mode.py b/backend/grants_shared/src/grants_shared/api/maintenance_mode.py new file mode 100644 index 0000000..50865da --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/maintenance_mode.py @@ -0,0 +1,75 @@ +import logging +from collections.abc import Collection +from enum import StrEnum +from functools import cache + +from apiflask import APIFlask +from flask import request +from pydantic import Field + +from grants_shared.api.route_utils import raise_flask_error +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class MaintenanceModeLogEvent(StrEnum): + """Distinct, queryable event types for maintenance-mode log records.""" + + REQUEST_REJECTED = "maintenance_mode_request_rejected" + TASK_SKIPPED = "maintenance_mode_task_skipped" + + +class MaintenanceModeConfig(PydanticBaseEnvConfig): + """Configuration for maintenance mode. + + The flag is sourced from SSM Parameter Store and injected into each container + as the ``ENABLE_MAINTENANCE_MODE`` env var at task launch. Flipping it is an ops + action (update SSM + force-new-deployment), not a code deploy, so the value is + fixed for the lifetime of a task and can be read once and cached. + """ + + enable_maintenance_mode: bool = Field(False, alias="ENABLE_MAINTENANCE_MODE") + retry_after_seconds: int = Field(3600, alias="MAINTENANCE_RETRY_AFTER_SECONDS") + + +@cache +def get_maintenance_mode_config() -> MaintenanceModeConfig: + # Cached since the env var is resolved at task launch; changing it requires a + # new deployment (and therefore a new process) anyway. + return MaintenanceModeConfig() + + +def is_maintenance_mode_enabled() -> bool: + return get_maintenance_mode_config().enable_maintenance_mode + + +def register_maintenance_mode_handler(app: APIFlask, allowlist: Collection[str]) -> None: + """Register a ``before_request`` handler that returns a 503 for every request + while maintenance mode is enabled, except for paths in ``allowlist``. + + Register this after logging is initialized (so rejections still produce the + normal start/end request logs) and before authentication (so unauthenticated + clients receive a 503 rather than a 401). ``allowlist`` is the per-system set of + paths that must keep serving during a maintenance window (e.g. ``{"/health"}`` + so ALB/Docker healthchecks stay green). + """ + allowed_paths = frozenset(allowlist) + + @app.before_request + def reject_if_maintenance_mode() -> None: + if not is_maintenance_mode_enabled(): + return + + if request.path in allowed_paths: + return + + logger.info( + "Request rejected due to maintenance mode", + extra={"maintenance_mode_event": MaintenanceModeLogEvent.REQUEST_REJECTED}, + ) + raise_flask_error( + 503, + message="API is undergoing scheduled maintenance", + headers={"Retry-After": str(get_maintenance_mode_config().retry_after_seconds)}, + ) diff --git a/backend/grants_shared/src/grants_shared/api/response.py b/backend/grants_shared/src/grants_shared/api/response.py new file mode 100644 index 0000000..1085641 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/response.py @@ -0,0 +1,122 @@ +import dataclasses +import logging +from typing import Any, cast + +import apiflask +import flask + +from grants_shared.api.schemas.extension import MarshmallowErrorContainer +from grants_shared.pagination.pagination_models import PaginationInfo +from grants_shared.util.dict_util import flatten_dict + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True, eq=True) +class ValidationErrorDetail: + type: str + message: str = "" + field: str | None = None + value: Any | None = None + + +@dataclasses.dataclass +class ApiResponse: + """Base response model for all API responses.""" + + message: str + data: Any | None = None + warnings: list[ValidationErrorDetail] = dataclasses.field(default_factory=list) + errors: list[ValidationErrorDetail] = dataclasses.field(default_factory=list) + status_code: int = 200 + + pagination_info: PaginationInfo | None = None + facet_counts: dict | None = None + + +def redirect_response(location: str, code: int = 302) -> flask.Response: + """Wrapper around Flask redirects to handle typing issues""" + return cast(flask.Response, flask.redirect(location, code)) + + +def process_marshmallow_issues(marshmallow_issues: dict) -> list[ValidationErrorDetail]: + validation_errors: list[ValidationErrorDetail] = [] + + # Marshmallow structures its issues as + # {"path": {"to": {"value": ["issue1", "issue2"]}}} + # this flattens that to {"path.to.value": ["issue1", "issue2"]} + flattened_issues = flatten_dict(marshmallow_issues) + + # Take the flattened issues and create properly formatted + # error messages by translating the Marshmallow codes + for field, value in flattened_issues.items(): + if isinstance(value, list): + for item in value: + if not isinstance(item, MarshmallowErrorContainer): + msg = f"Unconfigured error in Marshmallow validation errors, expected MarshmallowErrorContainer, but got {item.__class__.__name__}" + logger.error(msg) + raise AssertionError(msg) + + # If marshmallow expects a field to be an object + # then it adds "._schema", we don't want that so trim it here + validation_errors.append( + ValidationErrorDetail( + field=field.removesuffix("._schema"), + message=item.message, + type=item.key, + ) + ) + else: + logger.error( + "Error format in json section was not formatted as expected, expected a list, got a %s", + type(value), + ) + + return validation_errors + + +def restructure_error_response(error: apiflask.exceptions.HTTPError) -> tuple[dict, int, Any]: + # Note that body needs to have the same schema as the ErrorResponseSchema we defined + # in app.api.route.schemas.response_schema.py + body = { + "message": error.message, + # we rename detail to data so success and error responses are consistent + "data": error.detail, + "status_code": error.status_code, + "internal_request_id": getattr(flask.g, "internal_request_id", None), + } + validation_errors: list[ValidationErrorDetail] = [] + + # Process Marshmallow issues and convert them to the proper format + # Marshmallow issues are put in the json error detail - the body of the request + if isinstance(error.detail, dict): + marshmallow_issues = error.detail.get("json") + if marshmallow_issues: + validation_errors.extend(process_marshmallow_issues(marshmallow_issues)) + + # We don't want to make the response confusing + # so we remove the now-duplicate error detail + del body["data"]["json"] + + marshmallow_issues = error.detail.get("headers") + if marshmallow_issues: + validation_errors.extend(process_marshmallow_issues(marshmallow_issues)) + + del body["data"]["headers"] + + marshmallow_issues = error.detail.get("form_and_files") + if marshmallow_issues: + validation_errors.extend(process_marshmallow_issues(marshmallow_issues)) + + del body["data"]["form_and_files"] + + # If we called raise_flask_error with a list of validation_issues + # then they get appended to the error response here + additional_validation_issues = error.extra_data.get("validation_issues") + if additional_validation_issues: + validation_errors.extend(additional_validation_issues) + + # Attach formatted errors to the error response + body["errors"] = validation_errors + + return body, error.status_code, error.headers diff --git a/backend/grants_shared/src/grants_shared/api/route_utils.py b/backend/grants_shared/src/grants_shared/api/route_utils.py new file mode 100644 index 0000000..834df2d --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/route_utils.py @@ -0,0 +1,25 @@ +from typing import Any, Never + +from apiflask import abort +from apiflask.types import ResponseHeaderType + +from grants_shared.api.response import ValidationErrorDetail + + +def raise_flask_error( + status_code: int, + message: str | None = None, + detail: Any = None, + headers: ResponseHeaderType | None = None, + validation_issues: list[ValidationErrorDetail] | None = None, + extra_data: dict | None = None, +) -> Never: + # Wrapper around the abort method which makes an error during API processing + # work properly when APIFlask generates a response. + # mypy doesn't realize this method never returns, so we define the same method + # with a return type of Never. + if extra_data is None: + extra_data = {} + extra_data["validation_issues"] = validation_issues + + abort(status_code, message, detail, headers, extra_data=extra_data) diff --git a/backend/grants_shared/src/grants_shared/api/schemas/__init__.py b/backend/grants_shared/src/grants_shared/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/__init__.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/__init__.py new file mode 100644 index 0000000..74cba9a --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/__init__.py @@ -0,0 +1,7 @@ +from . import field_validators as validators +from . import schema_fields as fields +from .schema import Schema +from .schema_common import MarshmallowErrorContainer +from .schema_validation_error import SchemaValidationError + +__all__ = ["fields", "validators", "Schema", "MarshmallowErrorContainer", "SchemaValidationError"] diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py new file mode 100644 index 0000000..e1701d7 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py @@ -0,0 +1,176 @@ +import copy +import typing + +from apiflask import validators # noqa: TID251 +from marshmallow import ValidationError +from marshmallow.validate import _SizedT # noqa: TID251 + +from grants_shared.api.schemas.extension.schema_common import MarshmallowErrorContainer +from grants_shared.api.schemas.extension.schema_validation_error import SchemaValidationError + +Validator = validators.Validator # re-export + + +class Regexp(validators.Regexp): + """ + Validator which can run a regex against a string. + """ + + def __init__( + self, + regex: str | bytes | typing.Pattern, + flags: int = 0, + error_message: str = "String does not match expected pattern.", + ): + """ + :param regex: The regular expression to run the text against. + :param flags: The regular expression flags to use. + :param error_message: Error message to raise in case of validation error. + """ + super().__init__(regex, flags, error=error_message) + + @typing.overload + def __call__(self, value: str) -> str: ... + + @typing.overload + def __call__(self, value: bytes) -> bytes: ... + + def __call__(self, value: str | bytes) -> str | bytes: + if self.regex.match(value) is None: # type: ignore + raise ValidationError( + [MarshmallowErrorContainer(SchemaValidationError.FORMAT, self.error)] + ) + + return value + + +class Length(validators.Length): + """Validator which succeeds if the value passed to it has a + length between a minimum and maximum. Uses len(), so it + can work for strings, lists, or anything with length. + + :param min: The minimum length. If not provided, minimum length + will not be checked. + :param max: The maximum length. If not provided, maximum length + will not be checked. + :param equal: The exact length. If provided, maximum and minimum + length will not be checked. + :param error: Error message to raise in case of a validation error. + Can be interpolated with `{input}`, `{min}` and `{max}`. + """ + + error_mapping: dict[str, MarshmallowErrorContainer] = { + "message_min": MarshmallowErrorContainer( + SchemaValidationError.MIN_LENGTH, "Shorter than minimum length {min}." + ), + "message_max": MarshmallowErrorContainer( + SchemaValidationError.MAX_LENGTH, "Longer than maximum length {max}." + ), + "message_all": MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_LENGTH, "Length must be between {min} and {max}." + ), + "message_equal": MarshmallowErrorContainer( + SchemaValidationError.EQUALS, "Length must be {equal}." + ), + } + + def _make_error(self, key: str) -> ValidationError: + try: + # Make a copy of the error mapping so we aren't modifying + # the class-level configurations above when we do formatting + error_container = copy.copy(self.error_mapping[key]) + except KeyError as error: + class_name = self.__class__.__name__ + message = ( + f"ValidationError raised by `{class_name}`, but error key `{key}` does " + "not exist in the `error_messages` dictionary." + ) + raise AssertionError(message) from error + + error_container.message = error_container.message.format( + min=self.min, max=self.max, equal=self.equal + ) + + return ValidationError([error_container]) + + def __call__(self, value: _SizedT) -> _SizedT: + length = len(value) + + if self.equal is not None: + if length != self.equal: + raise self._make_error("message_equal") + return value + + if self.min is not None and length < self.min: + key = "message_min" if self.max is None else "message_all" + raise self._make_error(key) + + if self.max is not None and length > self.max: + key = "message_max" if self.min is None else "message_all" + raise self._make_error(key) + + return value + + +class Email(validators.Email): + EMAIL_ERROR = MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "Not a valid email address." + ) + + def __call__(self, value: str) -> str: + try: + return super().__call__(value) + except ValidationError: + # Fix the validation error to have our format + raise ValidationError([self.EMAIL_ERROR]) from None + + +class URL(validators.URL): + URL_ERROR = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid URL.") + + def __call__(self, value: str) -> str: + try: + return super().__call__(value) + except ValidationError: + raise ValidationError([self.URL_ERROR]) from None + + +class OneOf(validators.OneOf): + """ + Validator which succeeds if ``value`` is a member of ``choices``. + + Use this when you want to limit the choices, but don't need the value to be an enum + """ + + CONTAINS_ONLY_ERROR = MarshmallowErrorContainer( + SchemaValidationError.INVALID_CHOICE, "Value must be one of: {choices_text}" + ) + + def __call__(self, value: typing.Any) -> typing.Any: + if value not in self.choices: + error_container = copy.copy(self.CONTAINS_ONLY_ERROR) + error_container.message = error_container.message.format(choices_text=self.choices_text) + raise ValidationError([error_container]) + + return value + + +_T = typing.TypeVar("_T") + + +class Range(validators.Range): + def _format_error(self, value: _T, message: str) -> list[MarshmallowErrorContainer]: # type: ignore + # The method this overrides returns a string, but we'll modify it to return one of + # our error containers instead which works, but MyPy doesn't like. + + is_min = self.min is not None + is_max = self.max is not None + + if is_min and is_max: + error_type = SchemaValidationError.MIN_OR_MAX_VALUE + elif is_min: + error_type = SchemaValidationError.MIN_VALUE + else: # must be max, init requires you set something + error_type = SchemaValidationError.MAX_VALUE + + return [MarshmallowErrorContainer(error_type, super()._format_error(value, message))] diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema.py new file mode 100644 index 0000000..0e2091f --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema.py @@ -0,0 +1,50 @@ +from typing import Any, cast + +import apiflask +from marshmallow import EXCLUDE + +from grants_shared.api.schemas.extension.schema_common import MarshmallowErrorContainer +from grants_shared.api.schemas.extension.schema_validation_error import SchemaValidationError + + +class Schema(apiflask.Schema): # noqa: TID251 + # There's no clean way to override the error messages at the schema-level + # as they get stored directly into the internal error store of the Schema object + # + # This approach is a little hacky, but we just change the default error messages to + # return the error container objects directly to work around that + _default_error_messages = cast( + dict[str, str], + { + "type": MarshmallowErrorContainer( + key=SchemaValidationError.INVALID, message="Invalid input type." + ), + "unknown": MarshmallowErrorContainer( + key=SchemaValidationError.UNKNOWN, message="Unknown field." + ), + }, + ) + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + # In order for the OpenAPI docs to display correctly + # we need to set sub-schemas as partial=True, as the + # apispec library doesn't handle recursively passing that down + # like it should through nested/list objects. + if self.partial is True: + for field in self.declared_fields.values(): + # If the field has nested, then it's a + # Nested field object + if hasattr(field, "nested"): + field.nested.partial = True + + # If the field has inner, then it's a list + # which has a nested schema within it + if hasattr(field, "inner"): + if hasattr(field.inner, "nested"): + field.inner.nested.partial = True + + class Meta: + # Ignore any extra fields + unknown = EXCLUDE diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_common.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_common.py new file mode 100644 index 0000000..93742fd --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_common.py @@ -0,0 +1,7 @@ +import dataclasses + + +@dataclasses.dataclass +class MarshmallowErrorContainer: + key: str + message: str diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_fields.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_fields.py new file mode 100644 index 0000000..5e749f0 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_fields.py @@ -0,0 +1,318 @@ +import copy +import enum +import typing + +from apiflask import fields as original_fields # noqa: TID251 +from marshmallow import ValidationError + +from grants_shared.api.schemas.extension.field_validators import URL as CustomURL +from grants_shared.api.schemas.extension.field_validators import Range +from grants_shared.api.schemas.extension.schema_common import MarshmallowErrorContainer +from grants_shared.api.schemas.extension.schema_validation_error import SchemaValidationError + + +class MixinField(original_fields.Field): + """ + Field mixin class to override the make_error method on each of + our field classes defined below. + + Note that in Python when a class inherits from multiple classes, + the left-most one takes precedence, so if any subclass of Field + were to modify the make_error method, that should take precedence + over this one. + + As make_error is only defined once in the Field class, this is fine + """ + + # Any derived class can specify an error_mapping object + # and it will be used / override the defaults here + error_mapping: dict[str, MarshmallowErrorContainer] = { + "required": MarshmallowErrorContainer( + SchemaValidationError.REQUIRED, "Missing data for required field." + ), + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Invalid value."), + "null": MarshmallowErrorContainer(SchemaValidationError.NOT_NULL, "Field may not be null."), + # not sure when this one gets hit, a failed validator uses the validator message + "validator_failed": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Invalid value." + ), + } + + def __init__(self, **kwargs: typing.Any) -> None: + super().__init__(**kwargs) + + # The actual error mapping used for a specific instance + self._error_mapping: dict[str, MarshmallowErrorContainer] = {} + + # This iterates over all classes and updates the error + # mapping with the most-specific class values overriding + # the most generic. + for cls in reversed(self.__class__.__mro__): + # Copy the error mapping values so any alterations don't + # affect other class objects + configured_error_mapping = getattr(cls, "error_mapping", {}) + for k, v in configured_error_mapping.items(): + self._error_mapping[k] = copy.copy(v) + + def make_error(self, key: str, **kwargs: typing.Any) -> ValidationError: + """Helper method to make a `ValidationError` with an error message + from ``self.error_mapping``. + """ + try: + error_container = self._error_mapping[key] + except KeyError as error: + class_name = self.__class__.__name__ + message = ( + f"ValidationError raised by `{class_name}`, but error key `{key}` does " + "not exist in the `error_mapping` dictionary." + ) + raise AssertionError(message) from error + + if kwargs: + error_container.message = error_container.message.format(**kwargs) + + return ValidationError([error_container]) + + +class String(original_fields.String, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid string."), + "invalid_utf8": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Not a valid utf-8 string." + ), + } + + +class Integer(original_fields.Integer, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid integer."), + } + + def __init__(self, restrict_to_32bit_int: bool = False, **kwargs: typing.Any): + # Restrict all integer values to 32-bits so that they can be stored in + # Postgres' integer column. If you wish to process a larger value, simply set this to false or specify + # your own min/max Range. + if restrict_to_32bit_int: + validators = kwargs.get("validate", []) + + # If a different range is specified, skip adding this one to avoid duplicate error messages + has_range_validator = False + for validator in validators: + if isinstance(validator, Range): + has_range_validator = True + break + + if not has_range_validator: + validators.append(Range(-2147483648, 2147483647)) + kwargs["validate"] = validators + + super().__init__(**kwargs) + + +class Boolean(original_fields.Boolean, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid boolean."), + } + + +class Decimal(original_fields.Decimal, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid decimal."), + "special": MarshmallowErrorContainer( + SchemaValidationError.SPECIAL_NUMERIC, + "Special numeric values (nan or infinity) are not permitted.", + ), + } + + +class UUID(original_fields.UUID, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid UUID."), + "invalid_uuid": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Not a valid UUID." + ), + } + + def __init__(self, **kwargs: typing.Any): + super().__init__(**kwargs) + + # Set a default value for the UUID if none supplied + example_value = kwargs.get("metadata", {}).get( + "example", "123e4567-e89b-12d3-a456-426614174000" + ) + self.metadata["example"] = example_value + + +class Date(original_fields.Date, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid date."), + "format": MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "'{input}' cannot be formatted as a date." + ), + } + + +class DateTime(original_fields.DateTime, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Not a valid datetime." + ), + "invalid_awareness": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Not a valid datetime." + ), + "format": MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "'{input}' cannot be formatted as a datetime." + ), + } + + +class List(original_fields.List, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid list."), + } + + +class Nested(original_fields.Nested, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "type": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Invalid type."), + } + + def __init__(self, nested: typing.Any, **kwargs: typing.Any): + super().__init__(nested=nested, **kwargs) + # We set this to object so that if it's nullable, it'll + # get generated in the OpenAPI to allow nullable + type_values = ["object"] + if self.allow_none: + type_values.append("null") + self.metadata["type"] = type_values + + +class Raw(original_fields.Raw, MixinField): + # No error mapping changed from the default + pass + + +class Dict(original_fields.Dict, MixinField): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid dict."), + } + + +class Enum(MixinField): + """ + Custom field class for handling unioning together multiple Python enums into + a single enum field in the generated openapi schema. + + For example, if you have an enum with values x, y, z, and another enum with values a, b, c + using this class all 6 of these values would be possible, and when the value + is deserialized, we would properly convert it to the proper enum object + """ + + error_mapping: dict[str, MarshmallowErrorContainer] = { + "unknown": MarshmallowErrorContainer( + SchemaValidationError.INVALID_CHOICE, "Must be one of: {choices}." + ), + } + + def __init__(self, *enums: type[enum.Enum], **kwargs: typing.Any) -> None: + super().__init__(**kwargs) + + self.enums = enums + self.field = Raw() + + self.enum_mapping = {} + + possible_choices = [] + for e in self.enums: + for raw_enum_value in e: + enum_value = str(self.field._serialize(raw_enum_value.value, None, None)) + possible_choices.append(enum_value) + self.enum_mapping[enum_value] = e + + self.choices_text = ", ".join(possible_choices) + # Set the enum metadata + self.metadata["enum"] = possible_choices + # Set the type so Swagger will know it's an enum-string + if self.metadata.get("type") is None: + type_values = ["string"] + if self.allow_none: + type_values.append("null") + self.metadata["type"] = type_values + + def _serialize( + self, value: typing.Any, attr: str | None, obj: typing.Any, **kwargs: typing.Any + ) -> typing.Any: + if value is None: + return None + + val = value + return self.field._serialize(val, attr, obj, **kwargs) + + def _deserialize( + self, + value: typing.Any, + attr: str | None, + data: typing.Mapping[str, typing.Any] | None, + **kwargs: typing.Any, + ) -> typing.Any: + val = self.field._deserialize(value, attr, data, **kwargs) + + # If the value isn't a string, we know + # it can't be a StrEnum + if not isinstance(val, str): + raise self.make_error("unknown", choices=self.choices_text) + + enum_type = self.enum_mapping.get(val) + if not enum_type: + raise self.make_error("unknown", choices=self.choices_text) + + return enum_type(val) + + +class File(original_fields.File, MixinField): + """A binary file for uploading schemas + + NOTE: This only works on requests, and the schema must be used + in a "form" or "form_and_fields" section, it does not work + as part of a JSON body. + + See: https://apiflask.com/request/#file-uploading + """ + + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid file."), + } + + +class Time(MixinField, original_fields.Time): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid time."), + "invalid_awareness": MarshmallowErrorContainer( + SchemaValidationError.INVALID, "Not a valid time." + ), + "format": MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "'{input}' cannot be formatted as a time." + ), + } + + +class URL(MixinField, original_fields.URL): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid URL."), + } + + def __init__(self, **kwargs: typing.Any): + super().__init__(**kwargs) + for i, validator in enumerate(self.validators): + if hasattr(validator, "error") and validator.error == "Not a valid URL.": + self.validators[i] = CustomURL() + + +class Float(MixinField, original_fields.Float): + error_mapping: dict[str, MarshmallowErrorContainer] = { + "invalid": MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid number."), + "special": MarshmallowErrorContainer( + SchemaValidationError.SPECIAL_NUMERIC, + "Special numeric values (nan or infinity) are not permitted.", + ), + } diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py new file mode 100644 index 0000000..9cb776b --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py @@ -0,0 +1,23 @@ +from enum import StrEnum + + +class SchemaValidationError(StrEnum): + """Contains error type codes that we can return from our schemas.""" + + REQUIRED = "required" + NOT_NULL = "not_null" + UNKNOWN = "unknown" + INVALID = "invalid" + + FORMAT = "format" + INVALID_CHOICE = "invalid_choice" + SPECIAL_NUMERIC = "special_numeric" + + MIN_LENGTH = "min_length" + MAX_LENGTH = "max_length" + MIN_OR_MAX_LENGTH = "min_or_max_length" + EQUALS = "equals" + + MIN_VALUE = "min_value" + MAX_VALUE = "max_value" + MIN_OR_MAX_VALUE = "min_or_max_value" diff --git a/backend/grants_shared/src/grants_shared/api/schemas/response_schema.py b/backend/grants_shared/src/grants_shared/api/schemas/response_schema.py new file mode 100644 index 0000000..74f5d0d --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/response_schema.py @@ -0,0 +1,76 @@ +from grants_shared.api.schemas.extension import Schema, fields +from grants_shared.pagination.pagination_schema import PaginationInfoSchema + + +class ValidationIssueSchema(Schema): + type = fields.String(metadata={"description": "The type of error", "example": "invalid"}) + message = fields.String( + metadata={"description": "The message to return", "example": "Not a valid string."} + ) + field = fields.String( + metadata={"description": "The field that failed", "example": "summary.summary_description"} + ) + value = fields.String( + metadata={"description": "The value that failed", "example": "invalid string"} + ) + + +class AbstractResponseSchema(Schema): + message = fields.String(metadata={"description": "The message to return", "example": "Success"}) + data = fields.MixinField(metadata={"description": "The REST resource object"}, dump_default={}) + status_code = fields.Integer( + metadata={"description": "The HTTP status code", "example": 200}, dump_default=200 + ) + + +class WarningMixinSchema(Schema): + warnings = fields.List( + fields.Nested(ValidationIssueSchema()), + metadata={ + "description": "A list of warnings - indicating something you may want to be aware of, but did not prevent handling of the request" + }, + dump_default=[], + ) + + +class PaginationMixinSchema(Schema): + pagination_info = fields.Nested( + PaginationInfoSchema(), + metadata={"description": "The pagination information for paginated endpoints"}, + ) + + +class ErrorResponseSchema(Schema): + data = fields.MixinField( + metadata={ + "description": "Additional data that might be useful in resolving an error (see specific endpoints for details, this is used infrequently)", + "example": {}, + }, + dump_default={}, + ) + message = fields.String( + metadata={"description": "General description of the error", "example": "Error"} + ) + status_code = fields.Integer(metadata={"description": "The HTTP status code of the error"}) + errors = fields.List( + fields.Nested(ValidationIssueSchema()), metadata={"example": []}, dump_default=[] + ) + internal_request_id = fields.String( + metadata={ + "description": "An internal tracking ID", + "example": "550e8400-e29b-41d4-a716-446655440000", + } + ) + + +class FileResponseSchema(Schema): + download_path = fields.String( + metadata={ + "description": "The file's download path", + }, + ) + file_size_bytes = fields.Integer( + metadata={"description": "The size of the file in bytes", "example": 1024} + ) + created_at = fields.DateTime(dump_only=True) + updated_at = fields.DateTime(dump_only=True) diff --git a/backend/grants_shared/src/grants_shared/api/schemas/search_schema.py b/backend/grants_shared/src/grants_shared/api/schemas/search_schema.py new file mode 100644 index 0000000..1371c40 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/api/schemas/search_schema.py @@ -0,0 +1,393 @@ +from collections.abc import Callable +from enum import StrEnum +from re import Pattern +from typing import Any + +from marshmallow import ValidationError, validates_schema + +from grants_shared.api.schemas.extension import ( + MarshmallowErrorContainer, + Schema, + fields, + validators, +) +from grants_shared.api.schemas.extension.schema_validation_error import SchemaValidationError + + +class BaseSearchSchema(Schema): + @validates_schema + def validates_non_empty(self, data: dict, **kwargs: Any) -> None: + """ + For any search schema, validates that the value provided actually has a filter set. + + For example, a request like: + + { + "filters": { + "my_field": {} + } + } + + would be invalid as "my_field" needs at least something within it. Note that providing + no filters / excluding "my_field" entirely is perfectly fine, we're just trying to avoid + having something partially filled out to keep the logic downstream a bit simpler. + """ + if data == {}: + raise ValidationError( + [ + MarshmallowErrorContainer( + SchemaValidationError.INVALID, "At least one filter rule must be provided." + ) + ] + ) + + +class BaseSearchSchemaBuilder: + def __init__(self, schema_class_name: str): + # schema fields are the fields and functions of the class + self.schema_fields: dict[str, fields.MixinField | Callable[..., Any]] = {} + # The schema class name is used on the endpoint + self.schema_class_name = schema_class_name + + def build(self) -> Schema: + return BaseSearchSchema.from_dict(self.schema_fields, name=self.schema_class_name) + + +class StrSearchSchemaBuilder(BaseSearchSchemaBuilder): + """ + Builder for setting up a filter in a search endpoint schema. + + Our schemas are setup to look like: + + { + "filters": { + "field": { + "one_of": ["x", "y", "z"] + } + } + } + + This helps generate the filters for a given field. At the moment, + only a one_of filter is implemented. + + Usage:: + + # In a search request schema, you would use it like so + + class OpportunitySearchFilterSchema(Schema): + example_enum_field = fields.Nested( + StrSearchSchemaBuilder("ExampleEnumFieldSchema") + .with_one_of(allowed_values=ExampleEnum) + .build() + ) + + example_str_field = fields.Nested( + StrSearchSchemaBuilder("ExampleStrFieldSchema") + .with_one_of(example="example_value", minimum_length=5) + .build() + ) + """ + + def with_one_of( + self, + *, + allowed_values: type[StrEnum] | None = None, + pattern: str | Pattern | None = None, + example: str | None = None, + minimum_length: int | None = None, + ) -> StrSearchSchemaBuilder: + if pattern is not None and allowed_values is not None: + raise Exception("Cannot specify both a pattern and allowed_values") + + metadata = {} + if example: + metadata["example"] = example + + # We assume it's just a list of strings + if allowed_values is None: + params: dict = {"metadata": metadata} + + field_validators: list[validators.Validator] = [] + if minimum_length is not None: + field_validators.append(validators.Length(min=minimum_length)) + + if pattern is not None: + field_validators.append(validators.Regexp(regex=pattern)) + + if len(field_validators) > 0: + params["validate"] = field_validators + + list_type: fields.MixinField = fields.String(**params) + + # Otherwise it is an enum type which handles allowed values + else: + list_type = fields.Enum(allowed_values, metadata=metadata) + + # Note that the list requires at least one value (sending us just [] will raise a validation error) + self.schema_fields["one_of"] = fields.List(list_type, validate=[validators.Length(min=1)]) + + return self + + +class IntegerSearchSchemaBuilder(BaseSearchSchemaBuilder): + """ + Builder for setting up a filter in a search endpoint schema for an integer. + + Our schemas are setup to look like: + + { + "filters": { + "field": { + "min": 1, + "max": 5 + } + } + } + + This helps generate the filters for a given field. At the moment, + only a min and max filter are implemented, and can be used to filter + on a range of values. + + Usage:: + + # In a search request schema, you would use it like so + + class OpportunitySearchFilterSchema(Schema): + example_int_field = fields.Nested( + IntegerSearchSchemaBuilder("ExampleIntFieldSchema") + .with_integer_range(min_example=1, max_example=25) + .build() + ) + """ + + def with_integer_range( + self, + min_example: int | None = None, + max_example: int | None = None, + positive_only: bool = True, + ) -> IntegerSearchSchemaBuilder: + self._with_minimum_value(min_example, positive_only) + self._with_maximum_value(max_example, positive_only) + self._with_int_range_validator() + return self + + def _with_minimum_value( + self, example: int | None = None, positive_only: bool = True + ) -> IntegerSearchSchemaBuilder: + metadata = {} + if example is not None: + metadata["example"] = example + + field_validators = [] + if positive_only: + field_validators.append(validators.Range(min=0)) + + self.schema_fields["min"] = fields.Integer( + allow_none=True, metadata=metadata, validate=field_validators + ) + return self + + def _with_maximum_value( + self, example: int | None = None, positive_only: bool = True + ) -> IntegerSearchSchemaBuilder: + metadata = {} + if example is not None: + metadata["example"] = example + + field_validators = [] + if positive_only: + field_validators.append(validators.Range(min=0)) + + self.schema_fields["max"] = fields.Integer( + allow_none=True, metadata=metadata, validate=field_validators + ) + return self + + def _with_int_range_validator(self) -> IntegerSearchSchemaBuilder: + # Define a schema validator function that we'll use to define any + # rules that go across fields in the validation + @validates_schema + def validate_int_range(_: Any, data: dict, **kwargs: Any) -> None: + min_value = data.get("min", None) + max_value = data.get("max", None) + + # Error if min and max value are None (either explicitly set, or because they are missing) + if min_value is None and max_value is None: + raise ValidationError( + [ + MarshmallowErrorContainer( + SchemaValidationError.REQUIRED, + "At least one of min or max must be provided.", + ) + ] + ) + + self.schema_fields["validate_int_range"] = validate_int_range + return self + + +class BoolSearchSchemaBuilder(BaseSearchSchemaBuilder): + """ + Builder for setting up a filter in a search endpoint schema. + + Our schemas are setup to look like: + + { + "filters": { + "field": { + "one_of": ["True", "False"] + } + } + } + + This helps generate the filters for a given field. At the moment, + only a one_of filter is implemented - note that any truthy value + as determined by Marshmallow is accepted (including "yes", "y", 1 - for true) + + While it doesn't quite make sense to filter by multiple boolean values in most cases, + we err on the side of consistency with the structure of the query to match other types. + + Usage:: + + # In a search request schema, you would use it like so + + class OpportunitySearchFilterSchema(Schema): + example_bool_field = fields.Nested( + BoolSearchSchemaBuilder("ExampleBoolFieldSchema") + .with_one_of(example=True) + .build() + ) + """ + + def with_one_of(self, example: bool | None = None) -> BoolSearchSchemaBuilder: + metadata = {} + if example is not None: + metadata["example"] = example + self.schema_fields["one_of"] = fields.List( + fields.Boolean(metadata=metadata), allow_none=True + ) + return self + + +class DateSearchSchemaBuilder(BaseSearchSchemaBuilder): + """ + Builder for setting up a filter for a range of dates in the search endpoint schema. + + Example of what this might look like: + { + "filters": { + "post_date": { + "start_date": "YYYY-MM-DD", + "end_date": "YYYY-MM-DD" + } + } + } + + Support for start_date and + end_date filters have been partially implemented. + + Usage:: + # In a search request schema, you would use it like so: + + example_startend_date_field = fields.Nested( + DateSearchSchemaBuilder("ExampleStartEndDateFieldSchema") + .with_date_range() + .build() + ) + """ + + def with_date_range(self) -> DateSearchSchemaBuilder: + self.schema_fields["start_date"] = fields.Date(allow_none=True) + self.schema_fields["end_date"] = fields.Date(allow_none=True) + + self.schema_fields["start_date_relative"] = fields.Integer( + allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)] + ) + self.schema_fields["end_date_relative"] = fields.Integer( + allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)] + ) + + self._with_date_range_validator() + + return self + + def _with_date_range_validator(self) -> DateSearchSchemaBuilder: + # Define a schema validator function that we'll use to define any + # rules that go across fields in the validation + @validates_schema + def validate_date_range(_: Any, data: dict, **kwargs: Any) -> None: + + start_date = data.get("start_date", None) + end_date = data.get("end_date", None) + + start_date_relative = data.get("start_date_relative", None) + end_date_relative = data.get("end_date_relative", None) + + # Error if both relative date and absolute date provided for either start or end date + if ("start_date" in data and "start_date_relative" in data) or ( + "end_date" in data and "end_date_relative" in data + ): + raise ValidationError( + [ + MarshmallowErrorContainer( + SchemaValidationError.INVALID, + "Cannot have both absolute and relative start/end date.", + ) + ] + ) + + # Error if both start and end date for either relative or absolute date are None (either explicitly set, or because they are missing) + if ( + start_date is None + and end_date is None + and start_date_relative is None + and end_date_relative is None + ): + raise ValidationError( + [ + MarshmallowErrorContainer( + SchemaValidationError.REQUIRED, + "At least one of start_date/start_date_relative or end_date/end_date_relative must be provided.", + ) + ] + ) + + self.schema_fields["validate_date_range"] = validate_date_range + return self + + +class UuidSearchSchemaBuilder(BaseSearchSchemaBuilder): + """Builder for setting up a filter for UUID values in a search endpoint schema. + + Our schemas are set up to look like: + { "filters": { "field": { "one_of": ["uuid1", "uuid2"] } } } + + This helps generate the filters for a given UUID field. Currently, only a `one_of` filter is implemented, + allowing the user to specify one or more UUIDs to match exactly. The `fields.UUID()` type ensures + strict UUID format validation (e.g., "f47ac10b-58cc-4372-a567-0e02b2c3d479"). + + While filtering by multiple UUIDs may not be common in all cases, we maintain consistency with + the overall filter structure used across other field types. + + Usage:: + # In a search request schema, you would use it like so + class UserApplicationFilterSchema(Schema): + example_uuid_field = fields.Nested( + UuidSearchSchemaBuilder("ExampleUuidFieldSchema") + .with_one_of() + .build() + ) + """ + + def with_one_of( + self, + *, + minimum_length: int | None = 1, + ) -> UuidSearchSchemaBuilder: + params: dict = {"allow_none": True} + if minimum_length is not None: + # Note that the list requires at least one value by default (sending us just [] will raise a validation error) + params["validate"] = [validators.Length(min=minimum_length)] + + self.schema_fields["one_of"] = fields.List(fields.UUID(), **params) + return self diff --git a/backend/grants_shared/src/grants_shared/auth/__init__.py b/backend/grants_shared/src/grants_shared/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/auth/api_jwt_auth.py b/backend/grants_shared/src/grants_shared/auth/api_jwt_auth.py new file mode 100644 index 0000000..cb63207 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/auth/api_jwt_auth.py @@ -0,0 +1,186 @@ +import logging +import uuid +from datetime import datetime, timedelta +from typing import Any + +import jwt +from pydantic import Field + +import grants_shared.util.datetime_util as datetime_util +from grants_shared.auth.auth_errors import JwtValidationError +from grants_shared.auth.auth_handler import AbstractAuthHandler +from grants_shared.db.models.auth_base_models import BaseUser, BaseUserTokenSession +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + + +class ApiJwtConfig(PydanticBaseEnvConfig): + + private_key: str = Field(alias="API_JWT_PRIVATE_KEY") + public_key: str = Field(alias="API_JWT_PUBLIC_KEY") + + issuer: str = Field("simpler-grants-api", alias="API_JWT_ISSUER") + audience: str = Field("simpler-grants-api", alias="API_JWT_AUDIENCE") + + algorithm: str = Field("RS256", alias="API_JWT_ALGORITHM") + + token_expiration_minutes: int = Field(30, alias="API_JWT_TOKEN_EXPIRATION_MINUTES") + + +# Initialize a config at startup that we'll use below +_config: ApiJwtConfig | None = None + + +def initialize_jwt_auth() -> None: + global _config + if not _config: + _config = ApiJwtConfig() + logger.info( + "Constructed JWT configuration", + extra={ + # NOTE: We don't just log the entire config + # because that would include the encryption keys + "issuer": _config.issuer, + "audience": _config.audience, + "algorithm": _config.algorithm, + "token_expiration_minutes": _config.token_expiration_minutes, + }, + ) + + +def get_config() -> ApiJwtConfig: + global _config + + if _config is None: + raise Exception("No JWT configuration - initialize_jwt_auth() must be run first") + + return _config + + +def generate_jwt( + user_token_session: BaseUserTokenSession, + user: BaseUser, + current_time: datetime, + email: str | None = None, + config: ApiJwtConfig | None = None, +) -> str: + """Create the JWT payload and make it into a JWT""" + if config is None: + config = get_config() + + payload = { + "sub": str(user_token_session.token_id), + # iat -> issued at + "iat": current_time, + "aud": config.audience, + "iss": config.issuer, + "email": email, + "user_id": str(user.get_user_id()), + "session_duration_minutes": config.token_expiration_minutes, + } + + return jwt.encode(payload, config.private_key, algorithm="RS256") + + +class JwtAuth[USER: BaseUser, USER_TOKEN_SESSION: BaseUserTokenSession]: + """Generic JWT creation/parsing flow. + + The DB interactions go through the injected :class:`AbstractAuthHandler`, so this flow + can be reused across systems by binding the concrete user / token-session models. + """ + + def __init__( + self, + auth_handler: AbstractAuthHandler[USER, Any, Any, Any, USER_TOKEN_SESSION], + config: ApiJwtConfig | None = None, + ): + self.auth_handler = auth_handler + self.config = config if config is not None else get_config() + + def create_jwt_for_user( + self, user: USER, email: str | None = None + ) -> tuple[str, USER_TOKEN_SESSION]: + # Always do all time checks in UTC for consistency + current_time = datetime_util.utcnow() + expiration_time = current_time + timedelta(minutes=self.config.token_expiration_minutes) + + # Create the session in the DB + user_token_session = self.auth_handler.create_token_session( + user, uuid.uuid4(), expiration_time + ) + jwt_str = generate_jwt( + user_token_session, user, current_time=current_time, email=email, config=self.config + ) + + logger.info( + "Created JWT token", + extra={ + "auth.user_id": user.get_user_id(), + "auth.token_id": user_token_session.token_id, + }, + ) + return jwt_str, user_token_session + + def parse_jwt_for_user(self, token: str) -> USER_TOKEN_SESSION: + """Handle processing a jwt token, and connecting it to a user token session in our DB""" + current_timestamp = datetime_util.utcnow() + + try: + parsed_jwt: dict = jwt.decode( + token, + self.config.public_key, + algorithms=[self.config.algorithm], + issuer=self.config.issuer, + audience=self.config.audience, + options={ + "verify_signature": True, + "verify_iat": True, + "verify_aud": True, + "verify_iss": True, + # We do not set the following fields + # so do not want to validate. + "verify_exp": False, # expiration is managed in the DB + "verify_nbf": False, # Tokens are always fine to use immediately + }, + ) + + except jwt.ImmatureSignatureError as e: # IAT errors hit this + raise JwtValidationError("Token not yet valid") from e + except jwt.InvalidIssuerError as e: + raise JwtValidationError("Unknown Issuer") from e + except jwt.InvalidAudienceError as e: + raise JwtValidationError("Unknown Audience") from e + except jwt.PyJWTError as e: + # Every other error case wrap in the same generic error message. + logger.warning("Token parse failure: %r", repr(e)) + raise JwtValidationError("Unable to process token") from e + + sub_id = parsed_jwt.get("sub", None) + if sub_id is None: + raise JwtValidationError("Token missing sub field") + + token_session = self.auth_handler.get_token_session_by_token_id(sub_id) + + # We check both the token expires_at timestamp as well as an + # is_valid flag to make sure the token is still valid. + if token_session is None: + raise JwtValidationError("Token session does not exist") + if token_session.expires_at < current_timestamp: + raise JwtValidationError("Token expired") + if token_session.is_valid is False: + raise JwtValidationError("Token is no longer valid") + + return token_session + + +def refresh_token_expiration( + token_session: BaseUserTokenSession, config: ApiJwtConfig | None = None +) -> BaseUserTokenSession: + if config is None: + config = get_config() + + expiration_time = datetime_util.utcnow() + timedelta(minutes=config.token_expiration_minutes) + token_session.expires_at = expiration_time + + return token_session diff --git a/backend/grants_shared/src/grants_shared/auth/api_key_handler.py b/backend/grants_shared/src/grants_shared/auth/api_key_handler.py new file mode 100644 index 0000000..571c867 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/auth/api_key_handler.py @@ -0,0 +1,175 @@ +import abc +import logging +from collections.abc import Sequence +from typing import Any +from uuid import UUID + +from grants_shared.adapters import db +from grants_shared.adapters.aws.api_gateway_adapter import ApiGatewayConfig, import_api_key +from grants_shared.api.route_utils import raise_flask_error +from grants_shared.auth.auth_handler import AbstractAuthHandler +from grants_shared.db.models.auth_base_models import BaseUserApiKey +from grants_shared.util.api_key_gen import generate_api_key_id + +logger = logging.getLogger(__name__) + +# Maximum number of retries for key generation +MAX_KEY_GENERATION_RETRIES = 5 + + +class KeyGenerationError(Exception): + """Raised when unable to generate a unique API key after multiple retries.""" + + pass + + +class ApiGatewayIntegrationError(Exception): + """Raised when there's an error integrating with AWS API Gateway.""" + + pass + + +class AbstractApiKeyHandler[USER_API_KEY: BaseUserApiKey](abc.ABC, metaclass=abc.ABCMeta): + + def __init__(self, db_session: db.Session): + self.db_session = db_session + + @abc.abstractmethod + def get_auth_handler(self) -> AbstractAuthHandler[Any, Any, Any, USER_API_KEY, Any]: + """Get the auth handler that backs this key handler""" + pass + + def create_api_key(self, user_id: UUID, key_name: str) -> USER_API_KEY: + """Create an API key for a user and associate with API gateway""" + # Generate a unique key_id with collision detection + key_id = self._generate_unique_key_id() + + # Create the new API key in our database first + api_key = self.get_auth_handler().create_api_key(user_id, key_name, key_id) + + # Import the API key to AWS API Gateway + self._import_api_key_to_aws_gateway(api_key) + + logger.info( + "Created new API key", + extra=api_key.get_log_extra(), + ) + + return api_key + + def get_user_api_keys(self, user_id: UUID) -> Sequence[USER_API_KEY]: + """Get a user's API keys""" + logger.info("Getting API keys for user", extra={"user_id": user_id}) + + api_keys = self.get_auth_handler().list_api_keys_for_user(user_id) + + logger.info( + "Retrieved API keys for user", + extra={ + "user_id": user_id, + "api_key_count": len(api_keys), + }, + ) + + return api_keys + + def get_user_api_key(self, user_id: UUID, api_key_id: UUID) -> USER_API_KEY: + """Get a specific API key for a user""" + logger.info( + "Getting specific API key for user", + extra={ + "user_id": user_id, + "api_key_id": api_key_id, + }, + ) + + api_key = self.get_auth_handler().get_api_key_for_user(user_id, api_key_id) + + if api_key is None: + raise_flask_error(404, "API key not found") + + logger.info( + "Retrieved specific API key for user", + extra={ + "user_id": user_id, + "api_key_id": api_key_id, + }, + ) + + return api_key + + def delete_api_key(self, user_id: UUID, api_key_id: UUID) -> None: + """Delete an API key for a user""" + api_key = self.get_user_api_key(user_id, api_key_id) + + self.db_session.delete(api_key) + + logger.info( + "Deleted API key", + extra=api_key.get_log_extra(), + ) + + def rename_api_key(self, user_id: UUID, api_key_id: UUID, key_name: str) -> USER_API_KEY: + """Rename an existing API key for a user""" + api_key = self.get_user_api_key(user_id, api_key_id) + api_key.key_name = key_name + + logger.info( + "Renamed API key", + extra=api_key.get_log_extra(), + ) + + return api_key + + def _import_api_key_to_aws_gateway(self, api_key: USER_API_KEY) -> None: + """Import an API key to AWS API Gateway and associate it with a usage plan""" + try: + config = ApiGatewayConfig() + + # Use the log extra to get info for the description we put in api gateway + # Will look like "api_key_id=, user_id=" depending on what is added to the + # log extra function of the derived implementation. + description_info = ", ".join( + [f"{k.removeprefix('auth.')}={v}" for k, v in api_key.get_log_extra().items()] + ) + + gateway_response = import_api_key( + api_key=api_key.key_id, + name=api_key.key_name, + description=f"API key for {description_info}", + enabled=api_key.is_active, + usage_plan_id=config.default_usage_plan_id, + ) + + logger.info( + "Successfully imported API key to AWS API Gateway and associated with usage plan", + extra={ + "gateway_key_id": gateway_response.id, + "usage_plan_id": config.default_usage_plan_id, + } + | api_key.get_log_extra(), + ) + + except Exception as e: + # Re-raise as a domain-specific exception without additional logging + # since the AWS adapter already logs the underlying error + raise ApiGatewayIntegrationError("Failed to import API key to AWS API Gateway") from e + + def _generate_unique_key_id(self) -> str: + for _attempt in range(MAX_KEY_GENERATION_RETRIES): + key_id = generate_api_key_id() + + # Check if this key_id already exists + existing_key = self.get_auth_handler().get_api_key_by_key_id(key_id) + + if existing_key is None: + return key_id + + # If we get here, we failed to generate a unique key after all retries + logger.error( + "Failed to generate unique key_id after maximum retries", + extra={"max_retries": MAX_KEY_GENERATION_RETRIES}, + ) + raise KeyGenerationError( + f"Unable to generate unique API key after {MAX_KEY_GENERATION_RETRIES} attempts" + ) diff --git a/backend/grants_shared/src/grants_shared/auth/auth_errors.py b/backend/grants_shared/src/grants_shared/auth/auth_errors.py new file mode 100644 index 0000000..bb48dd1 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/auth/auth_errors.py @@ -0,0 +1,10 @@ +class JwtValidationError(Exception): + """ + Exception we will reraise if there are + any issues processing a JWT that should + cause the endpoint to raise a 401 + """ + + def __init__(self, message: str): + super().__init__(message) + self.message = message diff --git a/backend/grants_shared/src/grants_shared/auth/auth_handler.py b/backend/grants_shared/src/grants_shared/auth/auth_handler.py new file mode 100644 index 0000000..f3fbb4f --- /dev/null +++ b/backend/grants_shared/src/grants_shared/auth/auth_handler.py @@ -0,0 +1,87 @@ +"""Auth handler abstraction. + +The generic authN logic should not query or construct the concrete user tables +directly. Instead it goes through an :class:`AbstractAuthHandler`, which defines the +shape of every DB interaction the auth flow needs in terms of the abstract base models +(see ``auth_base_models.py``). The concrete :class:`AuthHandler` supplies the real +tables. + +This lets the generic auth logic be shared without depending on the concrete API tables. +""" + +import abc +import uuid +from collections.abc import Sequence +from datetime import datetime +from uuid import UUID + +from grants_shared.adapters import db +from grants_shared.db.models.auth_base_models import ( + BaseLinkExternalUser, + BaseLoginGovState, + BaseUser, + BaseUserApiKey, + BaseUserTokenSession, +) + + +# Type parameters bind the abstract models to concrete tables so handlers avoid casting. +# Multi-letter names (rather than the usual single letter) since there are five. +class AbstractAuthHandler[ + USER: BaseUser, + LINK_EXTERNAL: BaseLinkExternalUser, + LOGIN_GOV_STATE: BaseLoginGovState, + USER_API_KEY: BaseUserApiKey, + USER_TOKEN_SESSION: BaseUserTokenSession, +](abc.ABC, metaclass=abc.ABCMeta): + """Defines the DB interactions the auth flow relies on, in terms of abstract models. + + Concrete implementations supply the actual queries and object construction against + real tables, binding the type parameters to their concrete models. + """ + + def __init__(self, db_session: db.Session): + self.db_session = db_session + + # --- User token sessions --- + + @abc.abstractmethod + def create_token_session( + self, user: USER, token_id: uuid.UUID, expires_at: datetime + ) -> USER_TOKEN_SESSION: ... + + @abc.abstractmethod + def get_token_session_by_token_id(self, token_id: str) -> USER_TOKEN_SESSION | None: ... + + # --- API keys --- + + @abc.abstractmethod + def get_api_key_by_key_id(self, key_id: str) -> USER_API_KEY | None: ... + + @abc.abstractmethod + def create_api_key(self, user_id: UUID, key_name: str, key_id: str) -> USER_API_KEY: ... + + @abc.abstractmethod + def list_api_keys_for_user(self, user_id: UUID) -> Sequence[USER_API_KEY]: ... + + @abc.abstractmethod + def get_api_key_for_user(self, user_id: UUID, api_key_id: UUID) -> USER_API_KEY | None: ... + + # --- login.gov state --- + + @abc.abstractmethod + def create_login_gov_state(self, state_id: uuid.UUID, nonce: uuid.UUID) -> LOGIN_GOV_STATE: ... + + @abc.abstractmethod + def get_login_gov_state(self, state_id: str) -> LOGIN_GOV_STATE | None: ... + + # --- External user link / user creation --- + + @abc.abstractmethod + def get_link_external_user(self, external_user_id: str) -> LINK_EXTERNAL | None: ... + + @abc.abstractmethod + def create_user_with_external_link(self, external_user_id: str) -> LINK_EXTERNAL: ... + + @abc.abstractmethod + def get_user_for_external_link(self, external_user: LINK_EXTERNAL) -> USER: ... diff --git a/backend/grants_shared/src/grants_shared/auth/login_gov_jwt_auth.py b/backend/grants_shared/src/grants_shared/auth/login_gov_jwt_auth.py new file mode 100644 index 0000000..3ef8f4f --- /dev/null +++ b/backend/grants_shared/src/grants_shared/auth/login_gov_jwt_auth.py @@ -0,0 +1,298 @@ +import dataclasses +import logging +import urllib +import uuid +from datetime import timedelta + +import jwt +from pydantic import BaseModel, Field + +from grants_shared.auth.auth_errors import JwtValidationError +from grants_shared.util import datetime_util +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) +# This ACR value forces login.gov to require PIV/CAC authentication. Documentation: https://developers.login.gov/oidc/authorization/ +LOGIN_GOV_PIV_REQUIRED = "http://idmanagement.gov/ns/assurance/aal/2?hspd12=true" + + +class RedirectParams(BaseModel): + piv_required: bool | None = None + + +class LoginGovConfig(PydanticBaseEnvConfig): + """ + Configuration for login.gov JWT auth + + Do not create this directly, instead call get_login_gov_config + which will handle setting it up for you. + """ + + # Public keys likely won't ever be set by an env var, so it defaults + # to an empty dict and gets overriden by any call to _refresh_keys + public_key_map: dict[str, jwt.PyJWK | str] = Field( + alias="LOGIN_GOV_PUBLIC_KEY_MAP", default_factory=dict + ) + + encryption_algorithm: str = Field(alias="LOGIN_GOV_ENCRYPTION_ALGORITHM", default="RS256") + + client_id: str = Field(alias="LOGIN_GOV_CLIENT_ID") + acr_value: str = Field(alias="LOGIN_GOV_ACR_VALUE", default="urn:acr.login.gov:auth-only") + scope: str = Field(alias="LOGIN_GOV_SCOPE", default="openid email x509:presented") + is_piv_required: bool = Field(alias="IS_PIV_REQUIRED", default=False) + + # While all of these endpoints are under the same root, we define the full + # path each time because the local mock uses a different naming convention + login_gov_endpoint: str = Field(alias="LOGIN_GOV_ENDPOINT") + login_gov_jwk_endpoint: str = Field(alias="LOGIN_GOV_JWK_ENDPOINT") + login_gov_auth_endpoint: str = Field(alias="LOGIN_GOV_AUTH_ENDPOINT") + login_gov_token_endpoint: str = Field(alias="LOGIN_GOV_TOKEN_ENDPOINT") + login_gov_logout_endpoint: str = Field(alias="LOGIN_GOV_LOGOUT_ENDPOINT") + + # Where we send a user after they have successfully logged in + # for now we'll always send them to the same place (a frontend page) + login_final_destination: str = Field(alias="LOGIN_FINAL_DESTINATION") + + # Where we sent users after they have successfully logged out + logout_final_destination: str = Field(alias="LOGOUT_FINAL_DESTINATION") + + # The private key we gave login.gov for private_key_jwt validation in the token endpoint + # See: https://developers.login.gov/oidc/token/#client_assertion + login_gov_client_assertion_private_key: str = Field( + alias="LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY" + ) + + login_gov_redirect_scheme: str = Field(alias="LOGIN_GOV_REDIRECT_SCHEME", default="http") + + +# Initialize a config at startup +_config: LoginGovConfig | None = None + + +def initialize_login_gov_config() -> None: + global _config + if not _config: + _config = LoginGovConfig() + + logger.info( + "Constructed login.gov configuration", + extra={ + "login_gov_endpoint": _config.login_gov_endpoint, + "login_gov_jwk_endpoint": _config.login_gov_jwk_endpoint, + "login_gov_auth_endpoint": _config.login_gov_auth_endpoint, + }, + ) + + +def get_config() -> LoginGovConfig: + global _config + + if _config is None: + raise Exception( + "No Login.gov configuration - initialize_login_gov_config() must be run first" + ) + + return _config + + +@dataclasses.dataclass +class LoginGovUser: + user_id: str + email: str + x509_presented: bool | None = None + + +def _refresh_keys(config: LoginGovConfig) -> None: + """ + WARNING: + This implementation is technically incorrect as it does + not account for thread safety. If multiple threads attempt to + refresh the token at the same time, they will all set it separately. + + Assignment in python should be atomic, and the Python global-interpreter-lock + likely make this less risky, but there is no guarantee that this won't + cause issues as we use it. + + We will evaluate this behavior over time and see if it causes us any issues. + For now, we are fine accepting this risk as the complexity of caching this + in a thread-safe way (eg. database, redis, or using Python locks) + isn't seen as worthwhile at the moment. + """ + logger.info("Refreshing login.gov JWKs") + jwk_client = jwt.PyJWKClient(config.login_gov_jwk_endpoint) + public_keys = jwk_client.get_jwk_set() + + public_key_map: dict[str, jwt.PyJWK | str] = { + key.key_id: key for key in public_keys.keys if key.key_id is not None + } + + if public_key_map.keys() != config.public_key_map.keys(): + logger.info("Found login.gov JWKs %s", public_key_map.keys()) + + # This line is possibly an issue for the reasons described above. + config.public_key_map = public_key_map + + +def get_login_gov_client_assertion(config: LoginGovConfig | None = None) -> str: + """Generate a client assertion token for login.gov auth""" + if config is None: + config = get_config() + + # Docs recommend a 5 minute expiration time + current_time = datetime_util.utcnow() + expiration_time = current_time + timedelta(minutes=5) + + # See: https://developers.login.gov/oidc/token/#client_assertion + client_assertion_payload = { + "iss": config.client_id, + "sub": config.client_id, + "aud": config.login_gov_token_endpoint, + "jti": str(uuid.uuid4()), + "exp": expiration_time, + } + + return jwt.encode( + client_assertion_payload, config.login_gov_client_assertion_private_key, algorithm="RS256" + ) + + +def get_final_redirect_uri( + message: str, + token: str | None = None, + is_user_new: bool | None = None, + error_description: str | None = None, + login_piv_required_error: str | None = None, + config: LoginGovConfig | None = None, +) -> str: + if config is None: + config = get_config() + + params: dict = {"message": message} + + if token is not None: + params["token"] = token + + if is_user_new is not None: + params["is_user_new"] = int(is_user_new) # put booleans in the URL as 0/1 + + if error_description is not None: + params["error_description"] = error_description + if login_piv_required_error is not None: + params["login_piv_required_error"] = login_piv_required_error + + encoded_params = urllib.parse.urlencode(params) + + return f"{config.login_final_destination}?{encoded_params}" + + +def get_final_logout_redirect_uri( + message: str, + error_description: str | None = None, + config: LoginGovConfig | None = None, +) -> str: + """ + Get the destination where we should redirect to after the full logout flow has completed. + + Generally will be a defined page in our frontend website that handles post-logout UX. + """ + if config is None: + config = get_config() + + params: dict = {"message": message} + + if error_description is not None: + params["error_description"] = error_description + + encoded_params = urllib.parse.urlencode(params) + + return f"{config.logout_final_destination}?{encoded_params}" + + +def validate_token(token: str, nonce: str, config: LoginGovConfig | None = None) -> LoginGovUser: + if not config: + config = get_config() + + try: + # To get the KID, we need parse the jwt + unverified_token = jwt.api_jwt.decode_complete(token, options={"verify_signature": False}) + except jwt.DecodeError as e: + # This would mean the token was malformed - likely not a jwt at all + raise JwtValidationError("Unable to parse token - invalid format") from e + + # Get the KID (key ID) + kid: str | None = unverified_token.get("header", {}).get("kid", None) + if kid is None: + raise JwtValidationError("Auth token missing KID") + + public_key = _get_key_for_kid(kid, config) + + return _validate_token_with_key(token, nonce, public_key, config) + + +def _get_key_for_kid(kid: str, config: LoginGovConfig, refresh: bool = True) -> jwt.PyJWK | str: + """Get the public key for the given KID (Key ID)""" + key = config.public_key_map.get(kid, None) + if key is not None: + return key + + # Fetch the latest keys from login.gov and try again + if refresh: + _refresh_keys(config) + return _get_key_for_kid(kid, config, refresh=False) + + raise JwtValidationError("No public key could be found for token") + + +def _validate_token_with_key( + token: str, nonce: str, public_key: jwt.PyJWK | str, config: LoginGovConfig +) -> LoginGovUser: + # We are processing the id_token as described on: + # https://developers.login.gov/oidc/token/#token-response + try: + data = jwt.api_jwt.decode_complete( + token, + public_key, + algorithms=[config.encryption_algorithm], + issuer=config.login_gov_endpoint, + audience=config.client_id, + # By default these options are already set to validate + # but making it very clear / explicit the validations we are doing + options={ + "verify_signature": True, + "verify_exp": True, + "verify_iat": True, + "verify_nbf": True, + "verify_aud": True, + "verify_iss": True, + }, + ) + payload = data.get("payload", {}) + + payload_nonce = payload.get("nonce", None) + if payload_nonce != nonce: + raise JwtValidationError("Nonce does not match expected") + + user_id = payload["sub"] + email = payload["email"] + x509_presented = payload.get("x509_presented") + + return LoginGovUser(user_id=user_id, email=email, x509_presented=x509_presented) + + # Most exceptions will result in an outright error + # as the only change to calls to this function are the public keys + # we use to validate. Unless it is a public-key-validation related error + # just reraise as a JwtValidationError here + except KeyError as e: + raise JwtValidationError("Token Missing Required Field(s)") from e + except jwt.ExpiredSignatureError as e: + raise JwtValidationError("Expired Token") from e + except jwt.ImmatureSignatureError as e: # IAT and NBF errors hit this + raise JwtValidationError("Token not yet valid") from e + except jwt.InvalidIssuerError as e: + raise JwtValidationError("Unknown Issuer") from e + except jwt.InvalidAudienceError as e: + raise JwtValidationError("Unknown Audience") from e + except jwt.InvalidSignatureError as e: # Token signature does not match + raise JwtValidationError("Invalid Signature") from e + except jwt.PyJWTError as e: # Every other type of JWT error not caught above + raise JwtValidationError("Unable to process token") from e diff --git a/backend/grants_shared/src/grants_shared/db/__init__.py b/backend/grants_shared/src/grants_shared/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/db/models/__init__.py b/backend/grants_shared/src/grants_shared/db/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/db/models/auth_base_models.py b/backend/grants_shared/src/grants_shared/db/models/auth_base_models.py new file mode 100644 index 0000000..358b774 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/auth_base_models.py @@ -0,0 +1,81 @@ +"""Abstract base models describing the shape of the tables the auth layer relies on. + +These declare only the columns the generic authN logic needs to read or write. The +concrete tables (see ``user_models.py``) inherit from these and supply the +application-specific details (foreign keys, relationships, additional columns). + +Keeping the auth logic typed against these abstract bases lets us share that logic +without it referencing the concrete API tables directly. +""" + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy.orm import Mapped, mapped_column + +from grants_shared.db.models.base import Base + + +class BaseUser(Base): + __abstract__ = True + + def get_user_id(self) -> uuid.UUID: + """Get the user ID (ie. primary key) for this user. Does not need the primary key to be named + exactly user_id in the derived table, but does assume that the user doesn't have a multi-column primary key. + + Mainly used as a convenience in our authN logic which wants to put an ID in the JWTs we generate. + """ + primary_key = self.get_primary_key_value() + if len(primary_key) != 1: + raise Exception("Unexpected number of primary keys for user, expected exactly 1") + + return primary_key[0] + + def get_log_extra(self) -> dict[str, Any]: + """Get logging info, do not include anything secretive in this function - extend it in derived classes to add more""" + return {"auth.user_id": self.get_user_id()} + + +class BaseUserTokenSession(Base): + __abstract__ = True + + token_id: Mapped[uuid.UUID] = mapped_column(primary_key=True) + + expires_at: Mapped[datetime] + + # When a user logs out, we set this flag to False. + is_valid: Mapped[bool] = mapped_column(default=True) + + def get_log_extra(self) -> dict[str, Any]: + """Get logging info, do not include anything secretive in this function - extend it in derived classes to add more""" + return { + "auth.token_id": self.token_id, + "auth.expires_at": self.expires_at, + } + + +class BaseUserApiKey(Base): + __abstract__ = True + + key_name: Mapped[str] + key_id: Mapped[str] = mapped_column( + unique=True, index=True, comment="AWS API Gateway key identifier" + ) + last_used: Mapped[datetime | None] + is_active: Mapped[bool] = mapped_column(default=True) + + +class BaseLoginGovState(Base): + __abstract__ = True + + # https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes + nonce: Mapped[uuid.UUID] + + +class BaseLinkExternalUser(Base): + __abstract__ = True + + external_user_id: Mapped[str] = mapped_column(index=True, unique=True) + + email: Mapped[str] = mapped_column(index=True) diff --git a/backend/grants_shared/src/grants_shared/db/models/base.py b/backend/grants_shared/src/grants_shared/db/models/base.py new file mode 100644 index 0000000..fec7558 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/base.py @@ -0,0 +1,121 @@ +import uuid +from collections.abc import Iterable +from datetime import date, datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from sqlalchemy import TIMESTAMP, MetaData, Text, inspect +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column +from sqlalchemy.orm.util import identity_key +from sqlalchemy.sql.functions import now as sqlnow + +from grants_shared.util import datetime_util + +# Override the default naming of constraints +# to use suffixes instead: +# https://stackoverflow.com/questions/4107915/postgresql-default-constraint-names/4108266#4108266 +metadata = MetaData( + naming_convention={ + "ix": "%(table_name)s_%(column_0_name)s_idx", + "uq": "%(table_name)s_%(column_0_name)s_uniq", + "ck": "%(table_name)s_%(constraint_name)s_check", + "fk": "%(table_name)s_%(column_0_name)s_%(referred_table_name)s_fkey", + "pk": "%(table_name)s_pkey", + } +) + + +class Base(DeclarativeBase): + # Attach the metadata to the Base class so all tables automatically get added to the metadata + metadata = metadata + + # Override the default type that SQLAlchemy will map python types to. + # This is used if you simply define a column like: + # + # my_column: Mapped[str] + # + # If you provide a mapped_column attribute you can override these values + # + # See: https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html#mapped-column-derives-the-datatype-and-nullability-from-the-mapped-annotation + # for the default mappings + # + # See: https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html#orm-declarative-mapped-column-type-map + # for details on setting up this configuration. + type_annotation_map = { + # Always include a timezone for datetimes + datetime: TIMESTAMP(timezone=True), + # Explicitly use the Text column type for strings + str: Text, + # Always use the Postgres UUID column type + uuid.UUID: postgresql.UUID(as_uuid=True), + } + + @classmethod + def get_table_name(cls) -> str: + return cls.__tablename__ + + def _dict(self) -> dict: + return {c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs} + + def for_json(self) -> dict: + json_valid_dict = {} + dictionary = self._dict() + for key, value in dictionary.items(): + if isinstance(value, UUID) or isinstance(value, Decimal): + json_valid_dict[key] = str(value) + elif isinstance(value, date) or isinstance(value, datetime): + json_valid_dict[key] = value.isoformat() + else: + json_valid_dict[key] = value + + return json_valid_dict + + def __repr__(self) -> str: + values = [] + for k, v in self.for_json().items(): + values.append(f"{k}={v!r}") + + return f"<{self.__class__.__name__}({','.join(values)})" + + def __rich_repr__(self) -> Iterable[tuple[str, Any]]: + """Rich repr for interactive console. + + See https://rich.readthedocs.io/en/latest/pretty.html#rich-repr-protocol + """ + return self._dict().items() + + def get_primary_key_value(self) -> tuple: + """Get the primary key value for the model as a tuple.""" + # Note that identity_key returns some other info, but we just return the primary key tuple + return identity_key(instance=self)[1] + + def get_log_extra(self) -> dict[str, Any]: + """Get logging info, do not include anything secretive in this function - extend it in derived classes to add more""" + # nothing at this layer, derived classes can extend + return {} + + +def same_as_created_at(context: Any) -> Any: + return context.get_current_parameters()["created_at"] + + +@declarative_mixin +class TimestampMixin: + """Mixin to add created_at and updated_at columns to a model + https://docs.sqlalchemy.org/en/20/orm/declarative_mixins.html#mixing-in-columns + """ + + created_at: Mapped[datetime] = mapped_column( + nullable=False, + default=datetime_util.utcnow, + server_default=sqlnow(), + ) + + updated_at: Mapped[datetime] = mapped_column( + nullable=False, + default=same_as_created_at, + onupdate=datetime_util.utcnow, + server_default=sqlnow(), + ) diff --git a/backend/grants_shared/src/grants_shared/db/models/lookup/__init__.py b/backend/grants_shared/src/grants_shared/db/models/lookup/__init__.py new file mode 100644 index 0000000..7999781 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/lookup/__init__.py @@ -0,0 +1,14 @@ +from .lookup import Lookup, LookupConfig, LookupInt, LookupStr +from .lookup_registry import LookupRegistry +from .lookup_table import LookupTable +from .sync_lookup_values import sync_lookup_values + +__all__ = [ + "Lookup", + "LookupInt", + "LookupStr", + "LookupConfig", + "LookupTable", + "LookupRegistry", + "sync_lookup_values", +] diff --git a/backend/grants_shared/src/grants_shared/db/models/lookup/lookup.py b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup.py new file mode 100644 index 0000000..2a25b75 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup.py @@ -0,0 +1,117 @@ +import dataclasses +from abc import ABC, ABCMeta, abstractmethod +from enum import IntEnum, StrEnum + + +@dataclasses.dataclass +class Lookup[T: StrEnum | IntEnum](ABC, metaclass=ABCMeta): + """ + A class which handles mapping a specific enum + member to additional metadata. + + At the moment, it only specifies a lookup value in + the DB, but we can expand this to include configuration + for additional member-specific fields like whether the + field is deprecated, or should be excluded from the API schema + """ + + lookup_enum: T + lookup_val: int + + @abstractmethod + def get_description(self) -> str: + pass + + +class LookupStr(Lookup[StrEnum]): + def get_description(self) -> str: + return self.lookup_enum + + +class LookupInt(Lookup[IntEnum]): + def get_description(self) -> str: + return self.lookup_enum.name + + +class LookupConfig[T: StrEnum | IntEnum]: + """ + Configuration object for storing lookup mapping + information. Helps with the conversion of our + enums to lookup integers in the DB, and vice-versa. + """ + + _enums: tuple[type[T], ...] + _enum_to_lookup_map: dict[T, Lookup] + _int_to_lookup_map: dict[int, Lookup] + + def __init__(self, lookups: list[Lookup]) -> None: + enum_types_seen: set[type[T]] = set() + _enum_to_lookup_map: dict[T, Lookup] = {} + _int_to_lookup_map: dict[int, Lookup] = {} + + for lookup in lookups: + if lookup.lookup_enum in _enum_to_lookup_map: + raise AttributeError( + f"Duplicate lookup_enum {lookup.lookup_enum} defined, {lookup} + {_enum_to_lookup_map[lookup.lookup_enum]}" + ) + _enum_to_lookup_map[lookup.lookup_enum] = lookup + + if lookup.lookup_val <= 0: + raise AttributeError( + f"Only positive lookup_val values are allowed, {lookup} not allowed" + ) + + if lookup.lookup_val in _int_to_lookup_map: + raise AttributeError( + f"Duplicate lookup_val {lookup.lookup_val} defined, {lookup} + {_int_to_lookup_map[lookup.lookup_val]}" + ) + _int_to_lookup_map[lookup.lookup_val] = lookup + + enum_types_seen.add(lookup.lookup_enum.__class__) + + # Verify that for each enum in the config + # that all of the values were mapped + expected_enum_members = set() + for enum_type_seen in enum_types_seen: + expected_enum_members.update([e for e in enum_type_seen]) + + diff = expected_enum_members.difference(_enum_to_lookup_map) + if len(diff) > 0: + raise AttributeError( + f"Lookup config must define a mapping for all enum values, the following were missing: {diff}" + ) + + self._enums: tuple[type[T], ...] = tuple(enum_types_seen) + self._enum_to_lookup_map: dict[T, Lookup] = _enum_to_lookup_map + self._int_to_lookup_map: dict[int, Lookup] = _int_to_lookup_map + + def get_enums(self) -> tuple[type[T], ...]: + return self._enums + + def get_lookups(self) -> list[Lookup]: + return [lk for lk in self._enum_to_lookup_map.values()] + + def get_int_for_enum(self, e: T) -> int | None: + """ + Given an enum, get the lookup int for it in the DB + """ + lookup = self._enum_to_lookup_map.get(e) + if lookup is None: + return None + + return lookup.lookup_val + + def get_lookup_for_int(self, num: int) -> Lookup | None: + """ + Given a lookup int, get the lookup for it + """ + return self._int_to_lookup_map.get(num) + + def get_enum_for_int(self, num: int) -> T | None: + """ + Given a lookup int, get the enum for it (via the lookup object) + """ + lookup = self.get_lookup_for_int(num) + if lookup is None: + return None + return lookup.lookup_enum diff --git a/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_registry.py b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_registry.py new file mode 100644 index 0000000..3518190 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_registry.py @@ -0,0 +1,99 @@ +import logging +from collections.abc import Callable +from enum import Enum, IntEnum, StrEnum +from typing import Any, TypeVar + +from grants_shared.db.models.lookup.lookup import LookupConfig +from grants_shared.db.models.lookup.lookup_table import LookupTable + +logger = logging.getLogger(__name__) + +L = TypeVar("L", bound=LookupTable) + + +class LookupRegistry: + _lookup_registry: dict[type[LookupTable], LookupConfig] = {} + + @classmethod + def register_lookup(cls, lookup: LookupConfig) -> Callable[[type[L]], type[L]]: + """ + Attach the lookup class mapping to a particular lookup table. + + Can be used as:: + + @LookupRegistry.register_lookup(lookup_constants.MY_LOOKUP_CONFIG) + class LkMyLookup(LookupTable): + pass + + """ + + def decorator(lookup_table: type[L]) -> type[L]: + if lookup_table in cls._lookup_registry: + raise Exception( + f"Cannot attach lookup mapping to table {lookup_table.get_table_name()}, table already registered" + ) + + cls._lookup_registry[lookup_table] = lookup + + return lookup_table + + return decorator + + @classmethod + def _get_lookup_config(cls, lookup_table: type[LookupTable]) -> LookupConfig: + lookup_config = cls._lookup_registry.get(lookup_table) + if lookup_config is None: + raise Exception( + f"Table {lookup_table.get_table_name()} does not have a registered lookup_config via register_lookup" + ) + return lookup_config + + @classmethod + def get_lookup_int_for_enum( + cls, lookup_table: type[LookupTable], lookup_enum: StrEnum | IntEnum | None + ) -> int | None: + """ + Given a Lookup Table + Enum, get the lookup int value to store in the DB + """ + if lookup_enum is None: + return None + + lookup_config = cls._get_lookup_config(lookup_table) + + return lookup_config.get_int_for_enum(lookup_enum) + + @classmethod + def get_enum_for_lookup_int( + cls, lookup_table: type[LookupTable], lookup_val: int | None + ) -> Enum | None: + """ + Given a Lookup Table + lookup int, get the enum that is mapped to it + """ + if lookup_val is None: + return None + + lookup_config = cls._get_lookup_config(lookup_table) + + return lookup_config.get_enum_for_int(lookup_val) + + @classmethod + def is_valid_type_for_table( + cls, lookup_table: type[LookupTable], lookup_val: Any | None + ) -> bool: + """ + Given a Lookup Table + a value, return whether it is of a type configured for the that table. + + This makes sure we only try to write enums configured for a certain table to that table. + """ + + # None is always valid + if lookup_val is None: + return True + + lookup_config = cls._get_lookup_config(lookup_table) + + return isinstance(lookup_val, lookup_config.get_enums()) + + @classmethod + def get_sync_values(cls) -> dict[type[LookupTable], LookupConfig]: + return cls._lookup_registry diff --git a/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_table.py b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_table.py new file mode 100644 index 0000000..7a627c1 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/lookup/lookup_table.py @@ -0,0 +1,14 @@ +from typing import TypeVar + +from grants_shared.db.models.base import Base +from grants_shared.db.models.lookup import Lookup + +L = TypeVar("L", bound="LookupTable") + + +class LookupTable(Base): + __abstract__ = True + + @classmethod + def from_lookup(cls: type[L], lookup: Lookup) -> L: + raise NotImplementedError(f"from_lookup must be implemented by {cls.__name__}") diff --git a/backend/grants_shared/src/grants_shared/db/models/lookup/sync_lookup_values.py b/backend/grants_shared/src/grants_shared/db/models/lookup/sync_lookup_values.py new file mode 100644 index 0000000..6186e8b --- /dev/null +++ b/backend/grants_shared/src/grants_shared/db/models/lookup/sync_lookup_values.py @@ -0,0 +1,60 @@ +import logging + +import grants_shared.adapters.db as db +from grants_shared.adapters.db import PostgresDBClient +from grants_shared.adapters.db.clients.postgres_config import get_db_config +from grants_shared.db.models.lookup import Lookup, LookupRegistry, LookupTable + +logger = logging.getLogger(__name__) + + +def sync_lookup_values(db_client: PostgresDBClient | None = None) -> None: + """ + Sync lookup values to the DB, adding or updating any + values that aren't already present. + + Sync is based on the primary key integer of the lookup + tables, so changing the description will work, and adding + new ones is possible, but you cannot reuse existing numbers + which the utilities prevent anyways. + """ + logger.info("Beginning sync of lookup values to DB") + + if not db_client: + db_client = PostgresDBClient(get_db_config()) + + with db_client.get_session() as db_session, db_session.begin(): + sync_values = LookupRegistry.get_sync_values() + + for table, lookup_config in sync_values.items(): + _sync_lookup_for_table(table, lookup_config.get_lookups(), db_session) + + +def _sync_lookup_for_table( + table: type[LookupTable], lookups: list[Lookup], db_session: db.Session +) -> None: + log_extra: dict = {"table_name": table.get_table_name()} + logger.info("Syncing lookup values for table %s", table.get_table_name()) + + # Optimization: Read all rows into the db_session's identity map. This makes + # the select query that merge does reference the identity map cache instead of + # making a query individually for every lookup value. Locally this brought the + # runtime down from 3500ms to 180ms for ~20 tables & ~400 lookup values + _ = db_session.query(table).all() + + modified_lookup_count = 0 + for lookup in lookups: + instance: LookupTable = db_session.merge(table.from_lookup(lookup)) + if db_session.is_modified(instance): + logger.info("Updated lookup value in table %s to %r", table.get_table_name(), lookup) + modified_lookup_count += 1 + + log_extra["modified_lookup_count"] = modified_lookup_count + if modified_lookup_count == 0: + # This is just to make the logs clearer instead of seeing + # several "Syncing lookup values for table .." and then nothing in-between + logger.info( + "No modified lookup values for table %s", table.get_table_name(), extra=log_extra + ) + else: + logger.info("Updated lookup values for table %s", table.get_table_name(), extra=log_extra) diff --git a/backend/grants_shared/src/grants_shared/logs/__init__.py b/backend/grants_shared/src/grants_shared/logs/__init__.py new file mode 100644 index 0000000..403cc94 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/__init__.py @@ -0,0 +1,31 @@ +"""Module for initializing logging configuration for the application. + +There are two formatters for the log messages: human-readable and JSON. +The formatter that is used is determined by the environment variable +LOG_FORMAT. If the environment variable is not set, the JSON formatter +is used by default. See grants_shared.logs.formatters for more information. + +The logger also adds a PII mask filter to the root logger. See +grants_shared.logs.pii for more information. + +Usage: + import grants_shared.logs + + with grants_shared.logs.init("program name"): + ... + +Once the module has been initialized, the standard logging module can be +used to log messages: + +Example: + import logging + + logger = logging.getLogger(__name__) + logger.info("message") +""" + +import grants_shared.logs.config as config + + +def init(program_name: str) -> config.LoggingContext: + return config.LoggingContext(program_name) diff --git a/backend/grants_shared/src/grants_shared/logs/audit.py b/backend/grants_shared/src/grants_shared/logs/audit.py new file mode 100644 index 0000000..e318a7d --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/audit.py @@ -0,0 +1,129 @@ +# +# Application-level audit logging. +# +# See https://docs.python.org/3/library/audit_events.html +# https://docs.python.org/3/library/sys.html#sys.addaudithook +# https://www.python.org/dev/peps/pep-0578/ +# +import collections +import logging +import sys +from collections.abc import Hashable, Sequence +from typing import Any + +logger = logging.getLogger(__name__) + +AUDIT = 32 +logging.addLevelName(AUDIT, "AUDIT") + + +def init() -> None: + """Initialize the audit logging module to start + logging security audit events.""" + sys.addaudithook(handle_audit_event) + + +def handle_audit_event(event_name: str, args: tuple[Any, ...]) -> None: + # Define events to log and the arguments to log for each event. + # For more information about these events and what they mean, see https://peps.python.org/pep-0578/#suggested-audit-hook-locations + # For the full list of auditable events, see https://docs.python.org/3/library/audit_events.html + # Define this variable locally so it can't be modified by other modules. + + EVENTS_TO_LOG = { + # Detect dynamic execution of code objects. This only occurs for explicit + # calls, and is not raised for normal function invocation. + "exec": ("code_object",), + # Detect when a file is about to be opened. path and mode are the usual + # parameters to open if available, while flags is provided instead of + # mode in some cases. + "open": ("path", "mode", "flags"), + # Detect when a signal is sent to a process. + "os.kill": ("pid", "sig"), + # Detect when a file is renamed. + "os.rename": ("src", "dst", "src_dir_fd", "dst_dir_fd"), + # Detect when a subprocess is started. + "subprocess.Popen": ("executable", "args", "cwd", "_"), + # Detect access to network resources. The address is unmodified from the original call. + "socket.connect": ("socket", "address"), + "socket.getaddrinfo": ("host", "port", "family", "type", "protocol"), + # Detect when new audit hooks are being added. + "sys.addaudithook": (), + # Detects URL requests. + # Don't log data or headers because they may contain sensitive information. + "urllib.Request": ("url", "_", "_", "method"), + } + + if event_name not in EVENTS_TO_LOG: + return + + arg_names = EVENTS_TO_LOG[event_name] + log_audit_event(event_name, args, arg_names) + + +# Set the audit hook to be traceable so that coverage module can track calls to it +# The coverage module relies on Python's trace hooks +# (See https://coverage.readthedocs.io/en/7.1.0/howitworks.html#execution) +# According to the docs for sys.addaudithook, the audit hook is only traced if the callable +# has a __cantrace__ member that is set to a true value. +# (See https://docs.python.org/3/library/sys.html#sys.addaudithook) +handle_audit_event.__cantrace__ = True # type: ignore + + +def log_audit_event(event_name: str, args: Sequence[Any], arg_names: Sequence[str]) -> None: + """Log a message but only log recently repeated messages at intervals.""" + extra = { + f"audit.args.{arg_name}": arg + for arg_name, arg in zip(arg_names, args, strict=True) + if arg_name != "_" + } + + key = (event_name, repr(args)) + if key not in audit_message_count: + count = 1 + else: + count = audit_message_count[key] + 1 + audit_message_count[key] = count + + if count > 100 and count % 100 != 0: + return + + if count > 10 and count % 10 != 0: + return + + extra["count"] = count + + logger.log(AUDIT, event_name, extra=extra) + + +class LeastRecentlyUsedDict(collections.OrderedDict): + """A dict with a maximum size, evicting the least recently written key when full. + + Getting a key that is not present returns a default value of 0. + + Setting a key marks it as most recently used and removes the oldest key if full. + + May be useful for tracking the count of items where limited memory usage is needed even if + the set of items can be unlimited. + + Based on the example at + https://docs.python.org/3/library/collections.html#ordereddict-examples-and-recipes + """ + + def __init__(self, maxsize: int = 128, *args: Any, **kwargs: Any) -> None: + self.maxsize = maxsize + super().__init__(*args, **kwargs) + + def __getitem__(self, key: Hashable) -> int: + if key in self: + return super().__getitem__(key) + return 0 + + def __setitem__(self, key: Hashable, value: int) -> None: + if key in self: + self.move_to_end(key) + super().__setitem__(key, value) + if self.maxsize < len(self): + self.popitem(last=False) + + +audit_message_count = LeastRecentlyUsedDict() diff --git a/backend/grants_shared/src/grants_shared/logs/config.py b/backend/grants_shared/src/grants_shared/logs/config.py new file mode 100644 index 0000000..7df8127 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/config.py @@ -0,0 +1,165 @@ +import contextlib +import logging +import os +import platform +import pwd +import sys +from typing import Any, cast + +from pydantic_settings import SettingsConfigDict + +import grants_shared.logs.audit +import grants_shared.logs.formatters as formatters +import grants_shared.logs.pii as pii +from grants_shared.util.env_config import PydanticBaseEnvConfig + +logger = logging.getLogger(__name__) + +_original_argv = tuple(sys.argv) + + +class HumanReadableFormatterConfig(PydanticBaseEnvConfig): + message_width: int = formatters.HUMAN_READABLE_FORMATTER_DEFAULT_MESSAGE_WIDTH + + +class LoggingConfig(PydanticBaseEnvConfig): + model_config = SettingsConfigDict(env_prefix="log_", env_nested_delimiter="__") + + format: str = "json" + level: str = "INFO" + enable_audit: bool = False + human_readable_formatter: HumanReadableFormatterConfig = HumanReadableFormatterConfig() + + # Specify logging_level_overrides formatted as "=" like "newrelic=INFO,something.else=ERROR" + level_overrides: str | None = None + + +class LoggingContext(contextlib.AbstractContextManager[None]): + """ + A context manager for handling setting up the logging stream. + + To help facillitate being able to test logging, we need to be able + to easily create temporary output streams and then tear them down. + + When this context manager is torn down, the stream handler created + with it will be removed. + + For example: + ```py + import logging + + logger = logging.getLogger(__name__) + + with LoggingContext("example_program_name"): + # This log message will go to stdout + logger.info("example log message") + + # This log message won't go to stdout as the + # handler will have been removed + logger.info("example log message") + ``` + Note that any other handlers added to the root logger won't be affected + and calling this multiple times before exit would result in duplicate logs. + """ + + def __init__(self, program_name: str) -> None: + self._configure_logging() + log_program_info(program_name) + + def __enter__(self) -> None: + pass + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + # Remove the console handler to stop logs from being sent to stdout + # This is useful in the test suite, since multiple tests may initialize + # separate duplicate handlers. This allows for easier cleanup for each + # of those tests. + logging.root.removeHandler(self.console_handler) + + def _configure_logging(self) -> None: + """Configure logging for the application. + + Configures the root module logger to log to stdout. + Adds a PII mask filter to the root logger. + Also configures log levels third party packages. + """ + config = LoggingConfig() + + # Loggers can be configured using config functions defined + # in logging.config or by directly making calls to the main API + # of the logging module (see https://docs.python.org/3/library/logging.config.html) + # We opt to use the main API using functions like `addHandler` which is + # non-destructive, i.e. it does not overwrite any existing handlers. + # In contrast, logging.config.dictConfig() would overwrite any existing loggers. + # This is important during testing, since fixtures like `caplog` add handlers that would + # get overwritten if we call logging.config.dictConfig() during the scope of the test. + self.console_handler = logging.StreamHandler(sys.stdout) + formatter = get_formatter(config) + self.console_handler.setFormatter(formatter) + self.console_handler.addFilter(pii.mask_pii) + logging.root.addHandler(self.console_handler) + logging.root.setLevel(config.level) + + if config.enable_audit: + grants_shared.logs.audit.init() + + # Configure loggers for third party packages + logging.getLogger("alembic").setLevel(logging.INFO) + logging.getLogger("werkzeug").setLevel(logging.WARN) + logging.getLogger("sqlalchemy.pool").setLevel(logging.INFO) + logging.getLogger("sqlalchemy.dialects.postgresql").setLevel(logging.INFO) + + # Allow an env var to override logging config, mostly for development purposes + # Parsing string formatted like "logger1=INFO,logger2=ERROR" + if config.level_overrides is not None: + for override in config.level_overrides.split(","): + logger_override, level_override = override.split("=") + logging.getLogger(logger_override).setLevel(level_override) + + +def get_formatter(config: LoggingConfig) -> logging.Formatter: + """Return the formatter used by the root logger. + + The formatter is determined by the environment variable LOG_FORMAT. If the + environment variable is not set, the JSON formatter is used by default. + """ + if config.format == "human-readable": + return get_human_readable_formatter(config.human_readable_formatter) + return formatters.JsonFormatter() + + +def log_program_info(program_name: str) -> None: + logger.info( + "start %s: %s %s %s, hostname %s, pid %i, user %i(%s)", + program_name, + platform.python_implementation(), + platform.python_version(), + platform.system(), + platform.node(), + os.getpid(), + os.getuid(), + pwd.getpwuid(os.getuid()).pw_name, + extra={ + "hostname": platform.node(), + "cpu_count": os.cpu_count(), + # If mypy is run on a mac, it will throw a module has no attribute error, even though + # we never actually access it with the conditional. + # + # However, we can't just silence this error, because on linux (e.g. CI/CD) that will + # throw an unused “type: ignore” comment error. Casting to Any instead ensures this + # passes regardless of where mypy is being run + "cpu_usable": ( + len(cast(Any, os).sched_getaffinity(0)) + if "sched_getaffinity" in dir(os) + else "unknown" + ), + }, + ) + logger.info("invoked as: %s", " ".join(_original_argv)) + + +def get_human_readable_formatter( + config: HumanReadableFormatterConfig, +) -> formatters.HumanReadableFormatter: + """Return the human readable formatter used by the root logger.""" + return formatters.HumanReadableFormatter(message_width=config.message_width) diff --git a/backend/grants_shared/src/grants_shared/logs/decodelog.py b/backend/grants_shared/src/grants_shared/logs/decodelog.py new file mode 100644 index 0000000..783d31c --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/decodelog.py @@ -0,0 +1,156 @@ +# +# Make JSON logs easier to read when developing or troubleshooting. +# +# Expects JSON log lines or `docker-compose log` output on stdin and outputs plain text lines on +# stdout. +# +# This module intentionally has no dependencies outside the standard library so that it can be run +# as a script outside the virtual environment if needed. +# +# mypy: disallow-untyped-defs + +import datetime +import json +import sys +from collections.abc import Mapping + +RED = "\033[31m" +GREEN = "\033[32m" +BLUE = "\033[34m" +ORANGE = "\033[38;5;208m" +RESET = "\033[0m" +NO_COLOUR = "" + +DEFAULT_MESSAGE_WIDTH = 50 + +output_dates = None + + +def main() -> None: + """Main entry point when used as a script.""" + for line in sys.stdin: + processed = process_line(line) + if processed is not None: + sys.stdout.write(processed) + sys.stdout.write("\r\n") + + +def process_line(line: str) -> str | None: + """Process a line of the log and return the reformatted line.""" + line = line.rstrip() + if line and line[0] == "{": + # JSON format + return decode_json_line(line) + elif "| {" in line: + # `docker-compose logs ...` format + return decode_json_line(line[line.find("| {") + 2 :]) + # Anything else is left alone + return line + + +def decode_json_line(line: str) -> str | None: + """Decode a JSON log line and return the reformatted line.""" + try: + data = json.loads(line) + except json.decoder.JSONDecodeError: + return line + + name = data.pop("name", "-") + level = data.pop("levelname", "-") + func_name = data.pop("funcName", "-") + created = datetime.datetime.fromtimestamp( + float(data.pop("created", 0)), tz=datetime.timezone.utc + ) + message = data.pop("message", "-") + + if level == "AUDIT": + return None + + return format_line(created, name, func_name, level, message, data) + + +def format_line( + created: datetime.datetime, + logger_name: str, + func_name: str, + level: str, + message: str, + extra: Mapping[str, str], + message_width: int = DEFAULT_MESSAGE_WIDTH, +) -> str: + """Format log fields as a coloured string.""" + logger_name_color = color_for_name(logger_name) + level_color = color_for_level(level) + return f"{format_datetime(created)} {colorize(logger_name.ljust(36), logger_name_color)} {func_name:<28} {colorize(level.ljust(8), level_color)} {colorize(message.ljust(message_width), level_color)} {colorize(format_extra(extra), BLUE)}" + + +def colorize(text: str, color: str) -> str: + return f"{color}{text}{RESET}" + + +def color_for_name(name: str) -> str: + if name.startswith("src"): + return GREEN + elif name.startswith("sqlalchemy"): + return ORANGE + return NO_COLOUR + + +def color_for_level(level: str) -> str: + if level in ("WARNING", "ERROR", "CRITICAL"): + return RED + return NO_COLOUR + + +def format_datetime(created: datetime.datetime) -> str: + global output_dates + if output_dates is None: + # Check first line - if over 10h ago, output dates as well as time. + output_dates = 36000 < (datetime.datetime.now() - created).total_seconds() + if output_dates: + return created.isoformat(timespec="milliseconds") + else: + return created.time().isoformat(timespec="milliseconds") + + +EXCLUDE_EXTRA = { + "args", + "created", + "entity.guid", + "entity.name", + "entity.type", + "exc_info", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "span.id", + "thread", + "threadName", + "trace.id", + "traceId", + "deploy_github_ref", + "deploy_github_sha", +} + + +def format_extra(data: Mapping[str, str]) -> str: + return " ".join( + "%s=%s" % (key, value) + for key, value in data.items() + if key not in EXCLUDE_EXTRA and value is not None + ) + + +if __name__ == "__main__": + main() diff --git a/backend/grants_shared/src/grants_shared/logs/flask_logger.py b/backend/grants_shared/src/grants_shared/logs/flask_logger.py new file mode 100644 index 0000000..d2e2eb3 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/flask_logger.py @@ -0,0 +1,272 @@ +"""Module for adding standard logging functionality to a Flask app. + +This module configures an application's logger to add extra data +to all log messages. Flask application context data such as the +app name and request context data such as the request method, request url +rule, and query parameters are added to the log record. + +This module also configures the Flask application to log every +non-404 request. + +Usage: + import grants_shared.logs.flask_logger as flask_logger + + logger = logging.getLogger(__name__) + app = create_app() + + flask_logger.init_app(logger, app) +""" + +import logging +import os +import sys +import time +import uuid + +import flask +import newrelic.api.time_trace + +from grants_shared.util.deploy_metadata import get_deploy_metadata_config + +logger = logging.getLogger(__name__) +EXTRA_LOG_DATA_ATTR = "extra_log_data" + +_GLOBAL_LOG_CONTEXT: dict = {} + + +def init_general_logging(app_logger: logging.Logger, app_name: str, app_domain: str) -> None: + """Initialize logging that doesn't depend on a Flask app + + If possible, use init_app instead which is called when we + create a flask app, this is only necessary for scripts that + aren't possible to run via Flask like our Alembic migrations + """ + + # Need to add filters to each of the handlers rather than to the logger itself, since + # messages are passed directly to the ancestor loggers’ handlers bypassing any filters + # set on the ancestors. + # See https://docs.python.org/3/library/logging.html#logging.Logger.propagate + for handler in app_logger.handlers: + handler.addFilter(_add_global_context_info_to_log_record) + handler.addFilter(_add_request_context_info_to_log_record) + handler.addFilter(_add_new_relic_context_to_log_record) + handler.addFilter(_add_error_info_to_log_record) + + deploy_metadata = get_deploy_metadata_config() + + # Add some metadata to all log messages globally + add_extra_data_to_global_logs( + { + "app.name": app_name, + "app_name": "api", + "app_domain": app_domain, + "run_mode": get_run_mode(), + "environment": os.environ.get("ENVIRONMENT"), + "deploy_github_ref": deploy_metadata.deploy_github_ref, + "deploy_github_sha": deploy_metadata.deploy_github_sha, + "deploy_whoami": deploy_metadata.deploy_whoami, + } + ) + + app_logger.info("initialized flask logger") + + +def init_app(app_logger: logging.Logger, app: flask.Flask, app_domain: str) -> None: + """Initialize the Flask app logger. + + Adds Flask app context data and Flask request context data + to every log record using log filters. + See https://docs.python.org/3/howto/logging-cookbook.html#using-filters-to-impart-contextual-information + + Also configures the app to log every non-404 request using the given logger. + + Usage: + import grants_shared.logs.flask_logger as flask_logger + + logger = logging.getLogger(__name__) + app = create_app() + + flask_logger.init_app(logger, app) + """ + + # Add request context data to every log record for the current request + # such as request id, request method, request path, and the matching Flask request url rule + app.before_request( + lambda: add_extra_data_to_current_request_logs(_get_request_context_info(flask.request)) + ) + + app.before_request(_track_request_start_time) + app.before_request(_log_start_request) + app.after_request(_log_end_request) + + init_general_logging(app_logger, app.name, app_domain) + + +def add_extra_data_to_current_request_logs( + data: dict[str, str | int | float | bool | uuid.UUID | None], +) -> None: + """Add data to every log record for the current request.""" + if not flask.has_request_context(): + return + + extra_log_data = getattr(flask.g, EXTRA_LOG_DATA_ATTR, {}) + extra_log_data.update(data) + setattr(flask.g, EXTRA_LOG_DATA_ATTR, extra_log_data) + + +def add_extra_data_to_global_logs(data: dict[str, str | int | float | bool | None]) -> None: + """Add metadata to all logs for the rest of the lifecycle of this app process""" + global _GLOBAL_LOG_CONTEXT + _GLOBAL_LOG_CONTEXT.update(data) + + +def _track_request_start_time() -> None: + """Store the request start time in flask.g""" + flask.g.request_start_time = time.perf_counter() + + +def _log_start_request() -> None: + """Log the start of a request. + + This function handles the Flask's before_request event. + See https://tedboy.github.io/flask/interface_src.application_object.html#flask.Flask.before_request + + Additional info about the request will be in the `extra` field + added by `_add_request_context_info_to_log_record` + """ + logger.info("start request") + + +def _log_end_request(response: flask.Response) -> flask.Response: + """Log the end of a request. + + This function handles the Flask's after_request event. + See https://tedboy.github.io/flask/interface_src.application_object.html#flask.Flask.after_request + + Additional info about the request will be in the `extra` field + added by `_add_request_context_info_to_log_record` + """ + + logger.info( + "end request", + extra={ + "response.status_code": response.status_code, + "response.content_length": response.content_length, + "response.content_type": response.content_type, + "response.mimetype": response.mimetype, + "response.time_ms": (time.perf_counter() - flask.g.request_start_time) * 1000, + }, + ) + return response + + +def _add_request_context_info_to_log_record(record: logging.LogRecord) -> bool: + """Add request context data to the log record. + + If there is no request context, then do not add any data. + """ + if not flask.has_request_context(): + return True + + if flask.request is None: + raise Exception("") + + extra_log_data: dict[str, str] = getattr(flask.g, EXTRA_LOG_DATA_ATTR, {}) + record.__dict__.update(extra_log_data) + + return True + + +def _add_global_context_info_to_log_record(record: logging.LogRecord) -> bool: + global _GLOBAL_LOG_CONTEXT + record.__dict__ |= _GLOBAL_LOG_CONTEXT + + return True + + +def _get_request_context_info(request: flask.Request) -> dict: + internal_request_id = str(uuid.uuid4()) + flask.g.internal_request_id = internal_request_id + + data = { + "request.id": request.headers.get("x-amzn-requestid", ""), + "request.method": request.method, + "request.path": request.path, + "request.url_rule": str(request.url_rule), + # This ID is used to group all logs for a given request + # and is returned in the API response for any 4xx/5xx scenarios + "request.internal_id": internal_request_id, + } + + correlation_id = request.headers.get("X-Correlation-Id") + if correlation_id is not None: + data["request.correlation_id"] = correlation_id + + # Add query parameter data in the format request.query. = + # For example, the query string ?foo=bar&baz=qux would be added as + # request.query.foo = bar and request.query.baz = qux + # PII should be kept out of the URL, as URLs are logged in access logs. + # With that assumption, it is safe to log query parameters. + for key, value in request.args.items(): + data[f"request.query.{key}"] = value + return data + + +def _add_new_relic_context_to_log_record(record: logging.LogRecord) -> bool: + """Add New Relic tracing info to our log record.""" + + # This is not the recommended way of implementing this, but the alternatives + # either change the structure of our logging to not be JSON, or would + # entirely replace the formatter we have for outputting logs. + # + # The NewRelicContextFormatter calls this function internally when it + # creates the output object. + # + # This sets the following fields: + # entity.type + # entity.name + # entity.guid + # hostname + # span.id + # trace.id + newrelic_metadata = newrelic.api.time_trace.get_linking_metadata() + + record.__dict__ |= newrelic_metadata + + return True + + +def _add_error_info_to_log_record(record: logging.LogRecord) -> bool: + """Add a shorter form of the error message to our log record.""" + exc_info = getattr(record, "exc_info", None) + # exc_info is a 3-part tuple with the class, error obj, and traceback + if exc_info and len(exc_info) == 3: + # Add the exception class name to the logs, check that it + # is a class just in case there is some code path that sets this different. + if isinstance(exc_info[0], type): + record.__dict__["exc_info_cls"] = exc_info[0].__name__ + # If the error were `raise ValueError("example")`, the + # value of this would be "ValueError('example')" + record.__dict__["exc_info_short"] = repr(exc_info[1]) + + return True + + +def get_run_mode() -> str: + # We want to indicate whether the app was run as an API service + # or as a CLI - use the argv of the command we ran it with + # to determine that. + # CLI commands are always of the form "/path/to/flask " + # + # The API service can be started either as + # "/path/to/flask --app src.app run ..." --> When run locally + # "/api/.venv/bin/gunicorn src.app:create_app()" --> When run non-locally + # + # So we check for pieces that only appear in the API commands + + _original_argv = " ".join(sys.argv) + run_mode = "cli" + if "gunicorn" in _original_argv or "--app" in _original_argv: + run_mode = "service" + + return run_mode diff --git a/backend/grants_shared/src/grants_shared/logs/formatters.py b/backend/grants_shared/src/grants_shared/logs/formatters.py new file mode 100644 index 0000000..5b529c1 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/formatters.py @@ -0,0 +1,62 @@ +"""Log formatters for the API. + +This module defines two formatters, JsonFormatter for machine-readable logs to +be used in production, and HumanReadableFormatter for human readable logs to +be used used during development. + +See https://docs.python.org/3/library/logging.html#formatter-objects +""" + +import json +import logging +from datetime import datetime + +import grants_shared.logs.decodelog as decodelog +from grants_shared.util.json_util import json_encoder + + +class JsonFormatter(logging.Formatter): + """A logging formatter which formats each line as JSON.""" + + def format(self, record: logging.LogRecord) -> str: + # logging.Formatter.format adds the `message` attribute to the LogRecord + # see https://github.com/python/cpython/blob/main/Lib/logging/__init__.py#L690-L720 + super().format(record) + + # New Relic automatically maps log messages of certain names over + # the "message" field in their system. This includes mapping "msg" + # which is the unformatted version of a log message. This means the + # formatted message doesn't make it into New Relic + # https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/#json-logs + # + # To work around this, we copy the message field to a new field name + record.formatted_msg = getattr(record, "message", None) + + return json.dumps(record.__dict__, separators=(",", ":"), default=json_encoder) + + +HUMAN_READABLE_FORMATTER_DEFAULT_MESSAGE_WIDTH = decodelog.DEFAULT_MESSAGE_WIDTH + + +class HumanReadableFormatter(logging.Formatter): + """A logging formatter which formats each line + as color-code human readable text + """ + + message_width: int + + def __init__(self, message_width: int = HUMAN_READABLE_FORMATTER_DEFAULT_MESSAGE_WIDTH): + super().__init__() + self.message_width = message_width + + def format(self, record: logging.LogRecord) -> str: + message = super().format(record) + return decodelog.format_line( + datetime.fromtimestamp(record.created), + record.name, + record.funcName, + record.levelname, + message, + record.__dict__, + message_width=self.message_width, + ) diff --git a/backend/grants_shared/src/grants_shared/logs/pii.py b/backend/grants_shared/src/grants_shared/logs/pii.py new file mode 100644 index 0000000..f20b78c --- /dev/null +++ b/backend/grants_shared/src/grants_shared/logs/pii.py @@ -0,0 +1,97 @@ +"""Mask PII from log records. + +This module defines a filter that can be attached to a logger to mask PII +from log records. The filter is applied to all log records, and masks PII +that looks like social security numbers. + +You can add the filter to a handler: + +Example: + import logging + import grants_shared.logs.pii as pii + + handler = logging.StreamHandler() + handler.addFilter(pii.mask_pii) + logger = logging.getLogger(__name__) + logger.addHandler(handler) + +Or you can add the filter directly to a logger. +If adding the filter directly to a logger, take note that the filter +will not be called for child loggers. +See https://docs.python.org/3/library/logging.html#logging.Logger.propagate + +Example: + import logging + import grants_shared.logs.pii as pii + + logger = logging.getLogger(__name__) + logger.addFilter(pii.mask_pii) +""" + +import logging +import re +from typing import Any + + +def mask_pii(record: logging.LogRecord) -> bool: + # Loop through all entries in the record's __dict__ + # attribute and mask any things that look like PII. + # We will mask positional args separately below. + record.__dict__ |= { + key: _mask_pii_for_key(key, value) + for key, value in record.__dict__.items() + if key != "args" # Handle positional "args" separately + } + + # record.__dict__["args"] will contain positional arguments to logging calls. + # For example, a call like logger.info("%s %s", "foo", "bar") will result in a LogRecord + # with record.__dict__["args"] == ("foo", "bar") + # We want to mask the PII on each argument separately rather than trying to do a PII regex + # match on the entire args tuple. + args = record.__dict__["args"] + record.__dict__["args"] = tuple(map(_mask_pii, args)) + return True + + +# Regular expression to match a tax identifier (SSN), 9 digits with optional dashes. +# Matches between word boundaries (\b), except when: +# - Preceded by word character and dash (e.g. "ip-10-11-12-134") +# - Preceded by or followed by a decimal point (for floating point numbers) +TIN_RE = re.compile( + r""" + \b # word boundary + (? Any | None: + """ + Mask the given value if it has the pattern of a tax identifier + unless its key is one of the allowed values to avoid masking + something that looks like an SSN but is known to be safe (like a timestamp) + """ + if key in ALLOW_NO_MASK: + return value + return _mask_pii(value) + + +def _mask_pii(value: Any | None) -> Any | None: + if TIN_RE.search(str(value)): + return TIN_RE.sub("*********", str(value)) + return value diff --git a/backend/grants_shared/src/grants_shared/pagination/__init__.py b/backend/grants_shared/src/grants_shared/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/pagination/pagination_models.py b/backend/grants_shared/src/grants_shared/pagination/pagination_models.py new file mode 100644 index 0000000..5be28d1 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/pagination/pagination_models.py @@ -0,0 +1,85 @@ +import dataclasses +import math +from enum import StrEnum +from typing import Self + +from pydantic import BaseModel, Field + +from grants_shared.pagination.paginator import Paginator + + +class SortDirection(StrEnum): + ASCENDING = "ascending" + DESCENDING = "descending" + + def short_form(self) -> str: + if self == SortDirection.DESCENDING: + return "desc" + return "asc" + + +class SortOrderParams(BaseModel): + order_by: str + sort_direction: SortDirection + + +class PaginationParams(BaseModel): + page_offset: int + page_size: int + + sort_order: list[SortOrderParams] = Field(default_factory=list) + + +@dataclasses.dataclass +class SortOrder: + order_by: str + sort_direction: SortDirection + + +@dataclasses.dataclass +class PaginationInfo: + page_offset: int + page_size: int + + total_records: int + total_pages: int + + sort_order: list[SortOrder] + + @classmethod + def from_pagination_params( + cls, pagination_params: PaginationParams, paginator: Paginator + ) -> Self: + return cls( + page_offset=pagination_params.page_offset, + page_size=pagination_params.page_size, + total_records=paginator.total_records, + total_pages=paginator.total_pages, + sort_order=[ + SortOrder(p.order_by, p.sort_direction) for p in pagination_params.sort_order + ], + ) + + @classmethod + def from_search_response(cls, pagination_params: PaginationParams, total_records: int) -> Self: + # OpenSearch cannot return records past 10,000, so even if the count + # is greater, reduce it to 10,000 exactly. + if total_records > 10000: + total_records = 10000 + + # If the total records was reduced, the page count will get reduced + # accordingly. It's fine if the last page partially goes over 10k as + # in our request building logic to OpenSearch we will make sure this page + # fully fits within the 10k. + total_pages = int(math.ceil(total_records / pagination_params.page_size)) # noqa: RUF046 + + return cls( + page_offset=pagination_params.page_offset, + page_size=pagination_params.page_size, + total_records=total_records, + total_pages=total_pages, + sort_order=[ + SortOrder(order_by=p.order_by, sort_direction=p.sort_direction) + for p in pagination_params.sort_order + ], + ) diff --git a/backend/grants_shared/src/grants_shared/pagination/pagination_schema.py b/backend/grants_shared/src/grants_shared/pagination/pagination_schema.py new file mode 100644 index 0000000..792c43e --- /dev/null +++ b/backend/grants_shared/src/grants_shared/pagination/pagination_schema.py @@ -0,0 +1,160 @@ +from typing import Any + +from marshmallow import pre_load + +from grants_shared.api.schemas.extension import Schema, fields, validators +from grants_shared.pagination.pagination_models import SortDirection + + +class BasePaginationSchema(Schema): + + @pre_load + def before_load(self, item: Any, many: bool, **kwargs: dict) -> Any: + # If input is not a dict, just return it; Marshmallow will raise a ValidationError + if not isinstance(item, dict): + return item + + # If sort_order is used, don't change anything + # We'll assume they've migrated properly + if item.get("sort_order") is not None: + return item + + # While we wait for the frontend to start using the new multi-sort, automatically + # setup a monosort for them from the old fields. + if item.get("order_by") is not None and item.get("sort_direction") is not None: + item["sort_order"] = [ + {"order_by": item["order_by"], "sort_direction": item["sort_direction"]} + ] + return item + + +def generate_pagination_schema( + cls_name: str, + order_by_fields: list[str], + max_page_size: int = 5000, + default_sort_order: list[dict] | None = None, + default_page_size: int | None = None, + default_page_offset: int | None = None, +) -> type[Schema]: + """ + Generate a schema that describes the pagination for a pagination endpoint. + + cls_name will be what the model is named internally by Marshmallow and what OpenAPI shows. + order_by_fields can be a list of fields that the endpoint allows you to sort the response by + + This is functionally equivalent to specifying your own class like so: + + class MyPaginationSchema(Schema): + order_by = fields.String( + validate=[validators.OneOf(["id","created_at","updated_at"])], + required=True, + metadata={"description": "The field to sort the response by"} + ) + sort_direction = fields.Enum( + SortDirection, + required=True, + metadata={"description": "Whether to sort the response ascending or descending"}, + ) + page_size = fields.Integer( + required=True, + validate=[validators.Range(min=1)], + metadata={"description": "The size of the page to fetch", "example": 25}, + ) + page_offset = fields.Integer( + required=True, + validate=[validators.Range(min=1)], + metadata={"description": "The page number to fetch, starts counting from 1", "example": 1}, + ) + + """ + + sort_order_schema = Schema.from_dict( + { + "order_by": fields.String( + validate=[validators.OneOf(order_by_fields)], + required=True, + metadata={"description": "The field to sort the response by"}, + ), + "sort_direction": fields.Enum( + SortDirection, + required=True, + metadata={"description": "Whether to sort the response ascending or descending"}, + ), + }, + name=f"SortOrder{cls_name}", + ) + + additional_sort_order_params: dict = {} + if default_sort_order is not None: + additional_sort_order_params["load_default"] = default_sort_order + else: + additional_sort_order_params["required"] = True + + # page_size required unless default provided + page_size_params: dict = {} + if default_page_size is not None: + page_size_params["load_default"] = default_page_size + else: + page_size_params["required"] = True + + # page_offset required unless default provided + page_offset_params: dict = {} + if default_page_offset is not None: + page_offset_params["load_default"] = default_page_offset + else: + page_offset_params["required"] = True + + pagination_schema_fields = { + "sort_order": fields.List( + fields.Nested(sort_order_schema()), + metadata={"description": "The list of sorting rules"}, + validate=[validators.Length(min=1, max=5)], + **additional_sort_order_params, + ), + "page_size": fields.Integer( + validate=[validators.Range(min=1, max=max_page_size)], + metadata={"description": "The size of the page to fetch", "example": 25}, + **page_size_params, + ), + "page_offset": fields.Integer( + validate=[validators.Range(min=1)], + metadata={ + "description": "The page number to fetch, starts counting from 1", + "example": 1, + }, + **page_offset_params, + ), + } + return BasePaginationSchema.from_dict(pagination_schema_fields, name=cls_name) + + +class SortOrderSchema(Schema): + order_by = fields.String( + metadata={"description": "The field that the records were sorted by", "example": "id"} + ) + sort_direction = fields.Enum( + SortDirection, + metadata={"description": "The direction the records are sorted"}, + ) + + +class PaginationInfoSchema(Schema): + # This is part of the response schema to provide all pagination information back to a user + + page_offset = fields.Integer( + metadata={"description": "The page number that was fetched", "example": 1} + ) + page_size = fields.Integer( + metadata={"description": "The size of the page fetched", "example": 25} + ) + total_records = fields.Integer( + metadata={"description": "The total number of records fetchable", "example": 42} + ) + total_pages = fields.Integer( + metadata={"description": "The total number of pages that can be fetched", "example": 2} + ) + + sort_order = fields.List( + fields.Nested(SortOrderSchema()), + metadata={"description": "The sort order passed in originally"}, + ) diff --git a/backend/grants_shared/src/grants_shared/pagination/paginator.py b/backend/grants_shared/src/grants_shared/pagination/paginator.py new file mode 100644 index 0000000..62fbc0c --- /dev/null +++ b/backend/grants_shared/src/grants_shared/pagination/paginator.py @@ -0,0 +1,79 @@ +import math +from collections.abc import Sequence + +from sqlalchemy import Select, func, inspect + +import grants_shared.adapters.db as db +from grants_shared.db.models.base import Base + +DEFAULT_PAGE_SIZE = 25 + + +class Paginator[T: Base]: + """ + DB select statement paginator that helps with setting up queries + that you want to paginate into chunks. + + Any usage of this should make sure that the select query passed in + contains sorting information otherwise results may not be expected. + + Expected usage: + from sqlalchemy import desc, select + + from src.db.models.opportunity_models import Opportunity + from grants_shared.pagination.paginator import Paginator + + # Create a select statement that includes ordering and sorting + stmt = select(User).order_by(desc("opportunity_id")) + + # Add any filters + stmt = stmt.where(Opportunity.agency_code == "US-XYZ") + + # Use the paginator to get a specific page (page 2 in this case) + paginator: Paginator[Opportunity] = Paginator(stmt, db_session, page_size=10) + users: list[Opportunity] = paginator.page_at(page_offset=2) + + """ + + def __init__( + self, table_model: type[Base], stmt: Select, db_session: db.Session, page_size: int = 25 + ): + self.table_model = table_model + self.stmt = stmt + self.db_session = db_session + + if page_size <= 0: + raise ValueError("Page size must be at least 1") + + self.page_size = page_size + + self.total_records = _get_record_count(self.table_model, self.db_session, self.stmt) + self.total_pages = int(math.ceil(self.total_records / self.page_size)) # noqa: RUF046 + + def page_at(self, page_offset: int) -> Sequence[T]: + """ + Get a specific page for pagination + """ + if page_offset <= 0 or page_offset > self.total_pages: + return [] + + offset = self.page_size * (page_offset - 1) + + return ( + self.db_session.execute(self.stmt.offset(offset).limit(self.page_size)) + .unique() + .scalars() + .all() + ) + + +def _get_record_count(table_model: type[Base], db_session: db.Session, stmt: Select) -> int: + # Simplify the query to instead be select count(DISTINCT()) from + # and remove the order_by as we won't care for this query and it would just make it slower. + + primary_key = inspect(table_model).primary_key[0] + + count_stmt = stmt.order_by(None).with_only_columns( + func.count(primary_key.distinct()), maintain_column_froms=True + ) + return db_session.execute(count_stmt).scalar_one() diff --git a/backend/grants_shared/src/grants_shared/pagination/sorting_util.py b/backend/grants_shared/src/grants_shared/pagination/sorting_util.py new file mode 100644 index 0000000..d6ca0d6 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/pagination/sorting_util.py @@ -0,0 +1,70 @@ +"""Utility functions for applying sorting to SQLAlchemy queries.""" + +from sqlalchemy import asc, desc +from sqlalchemy.orm import InstrumentedAttribute +from sqlalchemy.sql.selectable import Select + +from grants_shared.db.models.base import Base +from grants_shared.pagination.pagination_models import SortDirection, SortOrderParams + + +def _resolve_column( + model_or_mapping: type[Base] | dict[str, InstrumentedAttribute], order_by: str +) -> InstrumentedAttribute: + if isinstance(model_or_mapping, dict): + column = model_or_mapping.get(order_by) + if column is None: + # This indicates a configuration error - the schema allows a sort field + # that we don't have a mapping for. This should be caught early. + msg = ( + f"Sort field '{order_by}' not found in column mapping. " + f"Available fields: {list(model_or_mapping.keys())}" + ) + raise ValueError(msg) + return column + + return getattr(model_or_mapping, order_by) + + +def apply_sorting( + stmt: Select, + sort_order: list[SortOrderParams], + model_or_mapping: type[Base] | dict[str, InstrumentedAttribute], + nulls_last: bool = False, +) -> Select: + """Apply sorting to a SQLAlchemy select statement. + + Columns to sort on are resolved one of two ways (never a mix): + * Pass a model class to look up each sort field via ``getattr(model, field)``. + * Pass a ``dict`` mapping sort field names to SQLAlchemy column objects, which + is useful when sorting fields come from joined tables. + Example: {"email": LinkExternalUser.email, "first_name": UserProfile.first_name} + + Args: + stmt: The SQLAlchemy query statement to apply sorting to + sort_order: List of SortOrderParams describing the sorting order + model_or_mapping: A model class (getattr approach) or a dict mapping sort field + names to column objects + nulls_last: When True, append NULLS LAST to each ordering so null values sort + to the end regardless of direction + + Returns: + The modified query statement with applied sorting + + Raises: + ValueError: If a mapping is provided and a sort field is not found in it + """ + order_cols = [] + + for order in sort_order: + column = _resolve_column(model_or_mapping, order.order_by) + + direction = asc if order.sort_direction == SortDirection.ASCENDING else desc + ordering = direction(column) + + if nulls_last: + ordering = ordering.nulls_last() + + order_cols.append(ordering) + + return stmt.order_by(*order_cols) diff --git a/backend/grants_shared/src/grants_shared/py.typed b/backend/grants_shared/src/grants_shared/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/services/__init__.py b/backend/grants_shared/src/grants_shared/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/services/users/__init__.py b/backend/grants_shared/src/grants_shared/services/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/services/users/login_gov_callback_handler.py b/backend/grants_shared/src/grants_shared/services/users/login_gov_callback_handler.py new file mode 100644 index 0000000..fe4c489 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/services/users/login_gov_callback_handler.py @@ -0,0 +1,218 @@ +import abc +import logging +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +from grants_shared.adapters.oauth.login_gov.login_gov_oauth_client import LoginGovOauthClient +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenRequest +from grants_shared.api.route_utils import raise_flask_error +from grants_shared.auth.api_jwt_auth import JwtAuth +from grants_shared.auth.auth_errors import JwtValidationError +from grants_shared.auth.auth_handler import AbstractAuthHandler +from grants_shared.auth.login_gov_jwt_auth import ( + LoginGovUser, + get_login_gov_client_assertion, + validate_token, +) +from grants_shared.db.models.auth_base_models import ( + BaseLinkExternalUser, + BaseLoginGovState, + BaseUser, + BaseUserTokenSession, +) +from grants_shared.util.string_utils import is_valid_uuid + +logger = logging.getLogger(__name__) + + +class CallbackParams(BaseModel): + code: str | None = None + state: str | None = None + error: str | None = None + error_description: str | None = None + + +@dataclass +class LoginGovDataContainer: + """Holds various login gov related fields we want to pass around""" + + code: str | None + nonce: str + + +@dataclass +class LoginGovCallbackResponse: + token: str + is_user_new: bool + + +def get_login_gov_client() -> LoginGovOauthClient: + """Get the login.gov client, in a method to be overridable in tests""" + return LoginGovOauthClient() + + +class AbstractLoginGovCallbackHandler[ + USER: BaseUser, + LINK_EXTERNAL: BaseLinkExternalUser, + LOGIN_GOV_STATE: BaseLoginGovState, + USER_TOKEN_SESSION: BaseUserTokenSession, +](abc.ABC, metaclass=abc.ABCMeta): + """Generic login.gov callback flow, reusable across systems. + + Everything that touches the DB goes through the injected :class:`AbstractAuthHandler`, + and any system-specific behavior after the user is resolved is supplied by the abstract + :meth:`handle_post_login` hook. Concrete handlers bind the type parameters to their real + tables, so the flow never needs to cast the user/link types. + """ + + def __init__( + self, + auth_handler: AbstractAuthHandler[ + USER, LINK_EXTERNAL, LOGIN_GOV_STATE, Any, USER_TOKEN_SESSION + ], + jwt_auth: JwtAuth[USER, USER_TOKEN_SESSION], + ): + self.auth_handler = auth_handler + self.jwt_auth = jwt_auth + self.db_session = auth_handler.db_session + + def handle_callback_request(self, query_data: dict) -> LoginGovDataContainer: + """Handle the callback from login.gov after calling the authenticate endpoint + + NOTE: Any errors thrown here will actually lead to a redirect due to the + with_login_redirect_error_handler handler we have attached to the route + """ + # Process the data coming back from login.gov via the redirect query params + # see: https://developers.login.gov/oidc/authorization/#authorization-response + callback_params = CallbackParams.model_validate(query_data) + + # If we got an error back in the callback, raise an exception + # The only two documented error values are access_denied and invalid_request + if callback_params.error is not None: + # access_denied means "The user has either cancelled or declined to authorize the client" + # so raise a 401 and redirect them back to the frontend + if callback_params.error == "access_denied": + raise_flask_error(401, "User declined to login") + + # Otherwise it's an invalid request which indicates a problem with our configuration + error_message = f"{callback_params.error} {callback_params.error_description}" + raise_flask_error(500, error_message) + + # This should only ever happen if someone directly calls the API + # We can't validate the request like normal due to the redirect nature + # of these endpoints. + if callback_params.code is None: + raise_flask_error(422, "Missing code in request") + if callback_params.state is None: + raise_flask_error(422, "Missing state in request") + + # If the state value we received isn't a valid UUID + # then it's likely someone randomly calling the endpoint + # We don't want this validation on the schema as it would + # occur before our error catching that handles redirects + if not is_valid_uuid(callback_params.state): + raise_flask_error(422, "Invalid OAuth state value") + + login_gov_state = self.auth_handler.get_login_gov_state(callback_params.state) + + # If we don't have the state value in our DB, that either means: + # * login.gov is very broken and sending us bad data + # * Someone called this endpoint directly with a random value + # + # There isn't a way to truly separate those here, so we'll assume the more likely second one + # and raise a 404 to say we have no idea what they passed us. + if login_gov_state is None: + raise_flask_error(404, "OAuth state not found") + + # We do not want the login_gov_state to be reusable - so delete it + # even if we later error to avoid any replay attacks. + self.db_session.delete(login_gov_state) + + return LoginGovDataContainer(code=callback_params.code, nonce=str(login_gov_state.nonce)) + + def handle_token(self, login_gov_data: LoginGovDataContainer) -> LoginGovCallbackResponse: + """Fetch user info from login gov, and handle user creation + + NOTE: Any errors thrown here will actually lead to a redirect due to the + with_login_redirect_error_handler handler we have attached to the route + """ + + # call the token endpoint (make a client) + # https://developers.login.gov/oidc/token/ + client = get_login_gov_client() + limit = 3 + tries = 0 + while tries < limit: + tries += 1 + response = client.get_token( + OauthTokenRequest( + code=login_gov_data.code, client_assertion=get_login_gov_client_assertion() + ) + ) + + # If this request failed, we'll check our retry policy and either retry or return the 500 if we're out of retries + if response.is_error_response(): + if tries == limit: + raise_flask_error(500, response.error_description) + else: + logger.info( + "Retrying call to Login.gov after receiving error", + extra={"tries": tries, "limit": limit}, + ) + continue + # if it's not an error we should break out of the loop since it was a successful call + break + # Process the token response from login.gov + # which will create/update a user in the DB + return self._process_token(response.id_token, login_gov_data.nonce) + + def _process_token(self, token: str, nonce: str) -> LoginGovCallbackResponse: + """Process the token from login.gov and generate our own token for auth""" + try: + login_gov_user = validate_token(token, nonce) + except JwtValidationError as e: + logger.info("Login.gov token validation failed", extra={"auth.issue": e.message}) + raise_flask_error(401, e.message) + + external_user = self.auth_handler.get_link_external_user(login_gov_user.user_id) + + is_user_new = external_user is None + + # If we didn't find anything, we want to create the user + if external_user is None: + external_user = self.auth_handler.create_user_with_external_link(login_gov_user.user_id) + + # Update fields on the external user table + # Store the email as lowercase, this should be how it's returned already + # but just to make email comparisons easier elsewhere we doubly make sure. + external_user.email = login_gov_user.email.lower() + + # Flush the records to the DB so any auto-generated IDs and similar are populated + # prior to us trying to work with the user further. + # NOTE: This doesn't commit yet - but effectively moves the cache from memory to the DB transaction + self.db_session.flush() + + user = self.auth_handler.get_user_for_external_link(external_user) + + # Apply any system-specific handling now that the user is resolved + self.handle_post_login(user, is_user_new, login_gov_user) + + token, user_token_session = self.jwt_auth.create_jwt_for_user( + user, email=external_user.email + ) + + logger.info("Generated token for user", extra=user_token_session.get_log_extra()) + + return LoginGovCallbackResponse(token=token, is_user_new=is_user_new) + + @abc.abstractmethod + def handle_post_login( + self, user: USER, is_user_new: bool, login_gov_user: LoginGovUser + ) -> None: + """Apply system-specific handling after the login.gov user is resolved. + + Each system supplies its own behavior here (e.g. organization linking, PIV checks), + since these depend on tables that only exist in that system. + """ diff --git a/backend/grants_shared/src/grants_shared/task/__init__.py b/backend/grants_shared/src/grants_shared/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/task/ecs_background_task.py b/backend/grants_shared/src/grants_shared/task/ecs_background_task.py new file mode 100644 index 0000000..bc394b2 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/task/ecs_background_task.py @@ -0,0 +1,146 @@ +import contextlib +import logging +import os +import time +import uuid +from collections.abc import Callable, Generator +from functools import wraps +from typing import ParamSpec, TypeVar, cast + +import newrelic.agent +import requests + +from grants_shared.api.maintenance_mode import MaintenanceModeLogEvent, is_maintenance_mode_enabled +from grants_shared.logs.flask_logger import add_extra_data_to_global_logs + +logger = logging.getLogger(__name__) + +P = ParamSpec("P") +T = TypeVar("T") + + +def ecs_background_task(task_name: str) -> Callable[[Callable[P, T]], Callable[P, T]]: + """ + Decorator for any ECS Task entrypoint function. + + This encapsulates the setup required by all ECS tasks, making it easy to: + - add new shared initialization steps for logging + - write new ECS task code without thinking about the boilerplate + + Usage: + The JobType enum is used to pass the task name into the ecs_background_task. A task + "my-cool-task" is translated to JobType.MY_COOL_TASK + + @task_blueprint.cli.command("my-cool-task", help="For running my cool task") + @ecs_background_task(JobType.MY_COOL_TASK) + @flask_db.with_db_session() + def entrypoint(db_session: db.Session): + do_cool_stuff() + + Parameters: + task_name (JobType): Job type of the ECS task + + IMPORTANT: Do not specify this decorator before the task command. + Click effectively rewrites your function to be a main function + and any decorators from before the "task_blueprint.cli.command(...)" + line are discarded. + See: https://click.palletsprojects.com/en/8.1.x/quickstart/#basic-concepts-creating-a-command + """ + + def decorator(f: Callable[P, T]) -> Callable[P, T]: + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + # Wrap with New Relic instrumentation + application = newrelic.agent.register_application(timeout=10.0) + with newrelic.agent.BackgroundTask(application, name=task_name, group="Python/ECSTask"): + # Wrap with our own logging (timing/general logs) + with _ecs_background_task_impl(task_name): + # During a maintenance window the DB is unavailable, so skip the + # task body rather than letting it fail mid-run. The task still + # fires on its cron schedule; it just logs the skip and exits + # cleanly. This runs inside the New Relic / logging wrapping so + # the skip is still recorded — nothing above touches the DB. + if is_maintenance_mode_enabled(): + logger.info( + "Skipping ECS task due to maintenance mode", + extra={"maintenance_mode_event": MaintenanceModeLogEvent.TASK_SKIPPED}, + ) + return cast(T, None) + # Finally actually run the task + return f(*args, **kwargs) + + return wrapper + + return decorator + + +@contextlib.contextmanager +def _ecs_background_task_impl(task_name: str) -> Generator[None]: + # The actual implementation, see the docs on the + # decorator method above for details on usage + start = time.perf_counter() + _add_log_metadata(task_name) + + logger.info("Starting ECS task %s", task_name) + + try: + yield + except Exception: + # We want to make certain that any exception will always + # be logged as an error + # logger.exception is just an alias for logger.error(, exc_info=True) + logger.exception("ECS task failed", extra={"status": "error"}) + raise + + end = time.perf_counter() + duration = round((end - start), 3) + logger.info( + "Completed ECS task %s", + task_name, + extra={"ecs_task_duration_sec": duration, "status": "success"}, + ) + + +def _add_log_metadata(task_name: str) -> None: + # Note we set an "aws.ecs.task_name" as well pulled from ECS + # which may be different as that value is set based on our infra setup + # while this one is just based on whatever we passed the @ecs_background_task decorator + add_extra_data_to_global_logs({"task_name": task_name, "task_uuid": str(uuid.uuid4())}) + add_extra_data_to_global_logs(_get_ecs_metadata()) + + +def _get_ecs_metadata() -> dict: + """ + Retrieves ECS metadata from an AWS-provided metadata URI. This URI is injected to all ECS tasks by AWS as an envar. + See https://docs.aws.amazon.com/AmazonECS/latest/userguide/task-metadata-endpoint-v4-fargate.html for more. + """ + ecs_metadata_uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4") + + if os.environ.get("ENVIRONMENT", "local") == "local" or ecs_metadata_uri is None: + logger.info( + "ECS metadata not available for local environments. Run this task on ECS to see metadata." + ) + return {} + + task_metadata = requests.get(ecs_metadata_uri, timeout=1) # 1sec timeout + logger.info("Retrieved task metadata from ECS") + metadata_json = task_metadata.json() + + ecs_task_name = metadata_json["Name"] + ecs_task_id = metadata_json["Labels"]["com.amazonaws.ecs.task-arn"].split("/")[-1] + ecs_taskdef = ":".join( + [ + metadata_json["Labels"]["com.amazonaws.ecs.task-definition-family"], + metadata_json["Labels"]["com.amazonaws.ecs.task-definition-version"], + ] + ) + # We don't currently send logs to Cloudwatch, and just send directly + # to NewRelic, so these error if we try to use them right now. + # cloudwatch_log_group = metadata_json["LogOptions"]["awslogs-group"] + # cloudwatch_log_stream = metadata_json["LogOptions"]["awslogs-stream"] + + return { + "aws.ecs.task_name": ecs_task_name, + "aws.ecs.task_id": ecs_task_id, + "aws.ecs.task_definition": ecs_taskdef, + } diff --git a/backend/grants_shared/src/grants_shared/util/__init__.py b/backend/grants_shared/src/grants_shared/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/src/grants_shared/util/api_key_gen.py b/backend/grants_shared/src/grants_shared/util/api_key_gen.py new file mode 100644 index 0000000..7e13cb5 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/api_key_gen.py @@ -0,0 +1,7 @@ +import secrets +import string + + +def generate_api_key_id(length: int = 25) -> str: + alphabet = string.ascii_letters + string.digits # a-z, A-Z, 0-9 + return "".join(secrets.choice(alphabet) for _ in range(length)) diff --git a/backend/grants_shared/src/grants_shared/util/datetime_util.py b/backend/grants_shared/src/grants_shared/util/datetime_util.py new file mode 100644 index 0000000..b72a9da --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/datetime_util.py @@ -0,0 +1,99 @@ +import re +import zoneinfo +from datetime import date, datetime, timezone + +import pytz + + +def utcnow() -> datetime: + """Current time in UTC tagged with timezone info marking it as UTC, unlike datetime.utcnow(). + + See https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow + """ + return datetime.now(timezone.utc) + + +def adjust_timezone(timestamp: datetime, timezone_str: str) -> datetime: + """ + Utility method for converting a datetime object + between different timezones. The string passed in + can be anything recognized by the pytz library + + Details on how to find all the potential timezone + names can be found in http://pytz.sourceforge.net/#helpers + but a few that are likely useful include: + * UTC + * US/Eastern + * US/Central + * US/Mountain + * US/Pacific + """ + new_timezone = pytz.timezone(timezone_str) + return timestamp.astimezone(new_timezone) + + +def make_timezone_aware(timestamp: datetime, timezone_str: str) -> datetime: + new_timezone = zoneinfo.ZoneInfo(timezone_str) + return timestamp.replace(tzinfo=new_timezone) + + +def get_now_us_eastern_datetime() -> datetime: + """ + Return the current time in the eastern time zone. DST is handled based on the local time. + For information on handling Daylight Savings Time, refer to this documentation on now() vs. utcnow(): + http://pytz.sourceforge.net/#problems-with-localtime + """ + + # Note that this uses Eastern time (not UTC) + tz = pytz.timezone("America/New_York") + return datetime.now(tz) + + +def get_now_us_eastern_date() -> date: + # We get the datetime and truncate it to the date portion + # as there aren't any direct date methods that take in a timezone + return get_now_us_eastern_datetime().date() + + +def datetime_str_to_date(datetime_str: str | None) -> date | None: + if not datetime_str: + return None + return datetime.fromisoformat(datetime_str).date() + + +def parse_grants_gov_date(date_str: str | None) -> date | None: + """ + Parse a date string from grants.gov SOAP API response. + + Grants.gov returns dates in formats like: + - "2025-09-16-04:00" (with timezone suffix) + - "2025-09-16" (standard ISO format) + + This function strips any timezone suffix and returns a date object. + + Args: + date_str: Date string from grants.gov API + + Returns: + date object or None if date_str is None/empty + + Raises: + ValueError: If date_str cannot be parsed as a valid date + """ + if not date_str or not date_str.strip(): + return None + + # Strip timezone suffix if present (e.g., "-04:00" or "+05:00") + # The pattern matches a hyphen or plus sign followed by HH:MM at the end of string + cleaned_date_str = re.sub(r"[+-]\d{2}:\d{2}$", "", date_str.strip()) + + try: + # Parse the cleaned date string + return datetime.fromisoformat(cleaned_date_str).date() + except ValueError as e: + raise ValueError(f"Could not parse date string '{date_str}': {e}") from e + + +def from_timestamp(timestamp: int) -> datetime: + """Convert an epoch timestamp (in milliseconds) into a datetime object in timezone aware UTC.""" + return datetime.fromtimestamp(timestamp / 1000.0, timezone.utc) diff --git a/backend/grants_shared/src/grants_shared/util/decimal_util.py b/backend/grants_shared/src/grants_shared/util/decimal_util.py new file mode 100644 index 0000000..494e22e --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/decimal_util.py @@ -0,0 +1,31 @@ +from decimal import Decimal, InvalidOperation +from typing import Any + +ZERO_DECIMAL = Decimal("0.00") # For formatting and defining 0 for decimal/monetary + + +def convert_monetary_field(value: Any) -> Decimal: + """Convert a monetary string to a decimal number. Can raise ValueError for invalid values.""" + + # We store monetary amounts as strings, for the purposes + # of doing math, we want to convert those to Decimals + if value is None: + return ZERO_DECIMAL + + if not isinstance(value, str): + raise ValueError("Cannot convert value to monetary field, is not a string") + + try: + return Decimal(value) + except InvalidOperation as e: + raise ValueError("Invalid decimal format, cannot process") from e + + +def quantize_decimal(value: Decimal) -> Decimal: + """ + Quantize a decimal number to always contain 2 values after the decimal. + + This uses the default behavior of quantizing and rounds UP + See: https://docs.python.org/3/library/decimal.html#decimal.ROUND_HALF_UP + """ + return value.quantize(ZERO_DECIMAL) diff --git a/backend/grants_shared/src/grants_shared/util/deploy_metadata.py b/backend/grants_shared/src/grants_shared/util/deploy_metadata.py new file mode 100644 index 0000000..659f3c5 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/deploy_metadata.py @@ -0,0 +1,72 @@ +import re +import typing +from datetime import datetime + +from pydantic_settings import SettingsConfigDict + +import grants_shared.util.datetime_util as datetime_util +from grants_shared.util.env_config import PydanticBaseEnvConfig + +# We expect release notes to be formatted as: +# YYYY-MM-DD-# +# However we don't always put leading zeroes, so all of the following +# would be valid release versions: +# 2024.11.27-1 +# 2024.11.5-1 +# 2024.4.30-1 +RELEASE_NOTE_REGEX = re.compile( + r""" + ^[0-9]{4} # Exactly 4 leading digits + (?:\.[0-9]{1,2}) # Period followed by 1-2 digits + (?:\.[0-9]{1,2}) # Period followed by 1-2 digits + (?:\-[0-9]{1,2})$ # Ends with a dash and 1-2 digits + """, + re.ASCII | re.VERBOSE, +) + + +class DeployMetadataConfig(PydanticBaseEnvConfig): + model_config = SettingsConfigDict(extra="allow") + + # We don't want these values being None to break + # any of our system, so allow them to be None + deploy_github_ref: str | None = None # DEPLOY_GITHUB_REF + deploy_github_sha: str | None = None # DEPLOY_GITHUB_SHA + deploy_timestamp: datetime | None = None # DEPLOY_TIMESTAMP + deploy_whoami: str | None = None # DEPLOY_WHOAMI + + def model_post_init(self, _context: typing.Any) -> None: + """Run after __init__ sets above values from env vars""" + + if self.deploy_github_ref and RELEASE_NOTE_REGEX.match(self.deploy_github_ref): + self.release_notes = ( + f"https://github.com/HHS/simpler-grants-gov/releases/tag/{self.deploy_github_ref}" + ) + else: + self.release_notes = "https://github.com/HHS/simpler-grants-gov/releases" + + if self.deploy_github_sha: + self.deploy_commit = ( + f"https://github.com/HHS/simpler-grants-gov/commit/{self.deploy_github_sha}" + ) + else: + self.deploy_commit = "https://github.com/HHS/simpler-grants-gov" + + if self.deploy_timestamp: + self.deploy_datetime_est = datetime_util.adjust_timezone( + self.deploy_timestamp, "US/Eastern" + ) + else: + # Just put when the API started up as a fallback + self.deploy_datetime_est = datetime_util.get_now_us_eastern_datetime() + + +_deploy_metadata_config: DeployMetadataConfig | None = None + + +def get_deploy_metadata_config() -> DeployMetadataConfig: + global _deploy_metadata_config + if _deploy_metadata_config is None: + _deploy_metadata_config = DeployMetadataConfig() + + return _deploy_metadata_config diff --git a/backend/grants_shared/src/grants_shared/util/dict_util.py b/backend/grants_shared/src/grants_shared/util/dict_util.py new file mode 100644 index 0000000..1bb91e3 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/dict_util.py @@ -0,0 +1,143 @@ +from typing import Any + +import jsonpath_ng + + +def flatten_dict(in_dict: Any, separator: str = ".", prefix: str = "") -> dict: + """ + Takes a set of nested dictionaries and flattens it + + For example:: + + { + "a": { + "b": { + "c": "value_c" + }, + "d": "value_d" + }, + "e": "value_e" + } + + Would become:: + + { + "a.b.c": "value_c", + "a.d": "value_d", + "e": "value_e" + } + """ + + if isinstance(in_dict, dict): + return_dict = {} + # Iterate over each item in the dictionary + for kk, vv in in_dict.items(): + # Flatten each item in the dictionary + for k, v in flatten_dict(vv, separator, str(kk)).items(): + # Update the path + new_key = prefix + separator + str(k) if prefix else str(k) + return_dict[new_key] = v + + return return_dict + + # value isn't a dictionary, so no more recursion + return {prefix: in_dict} + + +def diff_nested_dicts(dict1: dict, dict2: dict) -> list: + """ + Compare two dictionaries (possibly nested), return a list of differences + with 'field', 'before', and 'after' for each key. + + + :param dict1 : The first dictionary. + :param dict2 : The second dictionary. + :return : Returns a list of dictionaries representing the differences. + a""" + + flatt_dict1 = flatten_dict(dict1) + flatt_dict2 = flatten_dict(dict2) + + diffs: list = [] + + all_keys = set(flatt_dict1.keys()).union(flatt_dict2.keys()) # Does not keep order + + for k in all_keys: + values = [flatt_dict1.get(k, None), flatt_dict2.get(k, None)] + # convert values to set for comparison + v_a = _convert_iterables_to_set(values[0]) + v_b = _convert_iterables_to_set(values[1]) + + if v_a != v_b: + diffs.append({"field": k, "before": values[0], "after": values[1]}) + + return diffs + + +def _convert_iterables_to_set(data: Any) -> Any: + if isinstance(data, (list, tuple)): + if data and isinstance(data[0], (dict, list)): + return {tuple(d.items()) for d in data} + return set(data) + return data + + +def get_nested_value(data: dict, path: list[str]) -> Any: + """Fetch a value from a dictionary based on the nested path + + For example, if you have the following dict: + { + "path": { + "to": { + "some_field": 10 + }, + "another_field": "hello" + }, + "array_field": [ + { + "x": 1, + "y": "hello" + }, + { + "x": 3, + "y": "there", + "z": "words" + } + ] + } + + Passing in the following paths would give the following values: + ["path", "to", "some_field"] -> 10 + ["path", "another_field"] -> "hello" + [] -> Returns the whole dict back + ["something", "that", "isn't", "a", "path"] -> None + + Array Cases + ["array_field[*]", "x"] -> [2, 3] + ["array_field[0]", "x"] -> 2 + ["array_field[*]", "y"] -> ["hello", "there"] + ["array_field[0]", "y"] -> "hello" + ["array_field[*]", "z"] -> [None, "there"] + ["array_field[0]", "z"] -> None + ["array_field[*]"] -> [{"x": 2, "y": "hello"}, {"x": 3, "y": "there", "z": "words"}] # Note this is the same as just ["array_field"] with more steps + """ + # If no path, just return the data + if len(path) == 0: + return data + + # Use jsonpath_ng to parse the path and + # find the data + full_path = ".".join(path) + expr = jsonpath_ng.parse(full_path) + result = expr.find(data) + + # No results, return None, not an empty list + if len(result) == 0: + return None + # One result and the path didn't specify an array (anywhere, not just at end) + # then we want to return a single item + if len(result) == 1 and "[*]" not in full_path: + return result[0].value + + # Otherwise return the list of results + return [r.value for r in result] diff --git a/backend/grants_shared/src/grants_shared/util/env_config.py b/backend/grants_shared/src/grants_shared/util/env_config.py new file mode 100644 index 0000000..84f4a93 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/env_config.py @@ -0,0 +1,16 @@ +import os + +from pydantic_settings import BaseSettings, SettingsConfigDict + +import grants_shared + +# TODO - this wouldn't make sense when used as a package - fix? +env_file = os.path.join( + os.path.dirname(os.path.dirname(grants_shared.__file__)), + "config", + "%s.env" % os.getenv("ENVIRONMENT", "local"), +) + + +class PydanticBaseEnvConfig(BaseSettings): + model_config = SettingsConfigDict(env_file=env_file) diff --git a/backend/grants_shared/src/grants_shared/util/file_util.py b/backend/grants_shared/src/grants_shared/util/file_util.py new file mode 100644 index 0000000..b65de89 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/file_util.py @@ -0,0 +1,329 @@ +import os +import shutil +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import botocore.client +import smart_open +from botocore.config import Config +from werkzeug.utils import secure_filename + +from grants_shared.adapters.aws import S3Config, get_boto_session, get_s3_client +from grants_shared.util.env_config import PydanticBaseEnvConfig + +################################## +# Configs +################################## + + +class FileConfig(PydanticBaseEnvConfig): + # Maximum file upload size in bytes (2 GB) + max_file_upload_size_bytes: int = 2 * 1024 * 1024 * 1024 + + +# Module-level singleton for default S3Config +_s3_config = None +_file_config = None + + +def get_default_s3_config() -> S3Config: + """ + Returns a singleton instance of S3Config to avoid reading env vars repeatedly. + """ + global _s3_config + if _s3_config is None: + _s3_config = S3Config() + return _s3_config + + +def get_default_file_config() -> FileConfig: + """ + Returns a singleton instance of FileConfig to avoid reading env vars repeatedly. + """ + global _file_config + if _file_config is None: + _file_config = FileConfig() + return _file_config + + +################################## +# Path parsing utils +################################## + + +def is_s3_path(path: str | Path) -> bool: + return str(path).startswith("s3://") + + +def split_s3_url(path: str | Path) -> tuple[str, str]: + parts = urlparse(str(path)) + bucket_name = parts.netloc + prefix = parts.path.lstrip("/") + return bucket_name, prefix + + +def get_s3_bucket(path: str | Path) -> str: + return split_s3_url(path)[0] + + +def get_s3_file_key(path: str | Path) -> str: + return split_s3_url(path)[1] + + +def get_file_name(path: str) -> str: + return Path(path).name + + +def get_secure_file_name(path: str) -> str: + """Grabs the filename of the path and then makes it safe for further path operations + while removing non-ascii characters. + """ + return secure_filename(get_file_name(path)) + + +def join(*parts: str) -> str: + return os.path.join(*parts) + + +################################## +# File operations +################################## + + +def open_stream( + path: str | Path, mode: str = "r", encoding: str | None = None, content_type: str | None = None +) -> Any: + if is_s3_path(path): + s3_client = get_s3_client() + + so_config = Config( + max_pool_connections=10, + connect_timeout=60, + read_timeout=60, + retries={"max_attempts": 10}, + ) + + client_kwargs = {"config": so_config} + # By default all files uploaded to s3 have a generic "application/octet-stream" content-type + # which means if they're opened in a browser they always download. + # We can set the content type (mimetype) by passing it to the create multipart upload function. + # See: https://github.com/piskvorky/smart_open?tab=readme-ov-file#s3-advanced-usage + if content_type: + client_kwargs["S3.Client.create_multipart_upload"] = {"ContentType": content_type} + + so_transport_params = {"client_kwargs": client_kwargs, "client": s3_client} + + return smart_open.open(path, mode, transport_params=so_transport_params, encoding=encoding) + else: + return smart_open.open(path, mode, encoding=encoding) + + +def pre_sign_file_location(file_path: str, s3_config: S3Config | None = None) -> str: + if s3_config is None: + s3_config = get_default_s3_config() + + s3_client = get_s3_client(s3_config, get_boto_session()) + bucket, key = split_s3_url(file_path) + pre_sign_file_loc = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket, "Key": key}, + ExpiresIn=s3_config.presigned_s3_duration, + ) + if s3_config.aws_s3_endpoint_url: + # Only relevant when local, due to docker path issues + pre_sign_file_loc = pre_sign_file_loc.replace( + s3_config.aws_s3_endpoint_url, "http://localhost:9090" + ) + + return pre_sign_file_loc + + +def pre_sign_upload( + file_path: str, + content_type: str, + metadata: dict[str, str], + s3_config: S3Config | None = None, + include_if_none_match: bool = False, +) -> dict[str, Any]: + """Generate a presigned POST URL + body for uploading a file to s3. + + The returned dict has a ``url`` (where the caller POSTs the file) and + ``fields`` (form fields the caller must include in the POST). The + presigned policy: + + - Pins Content-Type so the caller can't override how s3 serves the file + back later. + - Sets each ``metadata`` entry as an ``x-amz-meta-`` field and pins + it in the conditions list so the caller can't tamper with it. This is + how the scanning lambda picks up identifiers (file id, user id, ...) + off the s3 object without needing a separate lookup. + - Bounds the upload size between 1 byte and + ``AppConfig.max_file_upload_size_bytes``. + - Sets ``IfNoneMatch: *`` so the URL can't be replayed to overwrite an + already-uploaded object. + + Args: + file_path: Full ``s3://bucket/key`` path where the upload will land. + content_type: MIME type of the file being uploaded. + metadata: Key→value pairs to attach as object metadata. Keys are + passed through to s3 as ``x-amz-meta-``. + s3_config: S3 configuration object, if not passed in, grabs the default s3_config + include_if_none_match: Whether to prevent a user from overwriting a file + """ + if s3_config is None: + s3_config = get_default_s3_config() + + file_config = get_default_file_config() + + s3_client = get_s3_client(s3_config) + bucket, key = split_s3_url(file_path) + + metadata_fields = {f"x-amz-meta-{k}": v for k, v in metadata.items()} + + conditions = [ + ["content-length-range", 1, file_config.max_file_upload_size_bytes], + {"Content-Type": content_type}, + *[{k: v} for k, v in metadata_fields.items()], + ] + + if include_if_none_match: + conditions.append({"IfNoneMatch": "*"}) + + presigned_post_result = s3_client.generate_presigned_post( + Bucket=bucket, + Key=key, + Fields={"Content-Type": content_type, **metadata_fields}, + Conditions=conditions, + ExpiresIn=s3_config.presigned_s3_duration, + ) + + # When running locally, swap out the presigned url to replace s3mock with localhost + # We never set the endpoint url non-locally, this is local-only. + if s3_config.aws_s3_endpoint_url: + # Only relevant when local, due to docker path issues + presigned_post_result["url"] = presigned_post_result.get("url", "").replace( + s3_config.aws_s3_endpoint_url, "http://localhost:9090" + ) + + return presigned_post_result + + +def get_file_length_bytes(path: str) -> int: + if is_s3_path(path): + s3_client = ( + get_s3_client() + ) # from our aws utils - handles hitting our s3mock if run locally + + bucket, key = split_s3_url(path) + file_metadata = s3_client.head_object(Bucket=bucket, Key=key) + return file_metadata["ContentLength"] + + file_stats = os.stat(path) + return file_stats.st_size + + +def copy_file(source_path: str | Path, destination_path: str | Path) -> None: + is_source_s3 = is_s3_path(source_path) + is_dest_s3 = is_s3_path(destination_path) + + # This isn't a download or upload method + # Don't allow "copying" between mismatched locations + if is_source_s3 != is_dest_s3: + raise Exception("Cannot download/upload between disk and S3 using this method") + + if is_source_s3: + s3_client = get_s3_client() + + source_bucket, source_path = split_s3_url(source_path) + dest_bucket, dest_path = split_s3_url(destination_path) + + s3_client.copy({"Bucket": source_bucket, "Key": source_path}, dest_bucket, dest_path) + else: + os.makedirs(os.path.dirname(destination_path), exist_ok=True) + shutil.copy2(source_path, destination_path) + + +def delete_file(path: str | Path) -> None: + """Delete a file from s3 or local disk""" + if is_s3_path(path): + bucket, s3_path = split_s3_url(path) + + s3_client = get_s3_client() + s3_client.delete_object(Bucket=bucket, Key=s3_path) + else: + os.remove(path) + + +def move_file(source_path: str | Path, destination_path: str | Path) -> None: + is_source_s3 = is_s3_path(source_path) + is_dest_s3 = is_s3_path(destination_path) + + # This isn't a download or upload method + # Don't allow "copying" between mismatched locations + if is_source_s3 != is_dest_s3: + raise Exception("Cannot download/upload between disk and S3 using this method") + + if is_source_s3: + copy_file(source_path, destination_path) + delete_file(source_path) + + else: + os.renames(source_path, destination_path) + + +def file_exists(path: str | Path) -> bool: + """Get whether a file exists or not""" + if is_s3_path(path): + s3_client = get_s3_client() + + bucket, key = split_s3_url(path) + + try: + s3_client.head_object(Bucket=bucket, Key=key) + return True + except botocore.exceptions.ClientError: + return False + + # Local file system + return Path(path).exists() + + +def read_file(path: str | Path, mode: str = "r", encoding: str | None = None) -> str: + """Simple function for just getting all of the contents of a file""" + with open_stream(path, mode, encoding) as input_file: + return input_file.read() + + +def write_to_file( + path: str | Path, content: str, encoding: str | None = None, content_type: str | None = None +) -> str: + """Simple function for replacing contents of a file""" + with open_stream(path, "w", encoding, content_type) as file_to_write_to: + return file_to_write_to.write(content) + + +def convert_public_s3_to_cdn_url(file_path: str, cdn_url: str, s3_config: S3Config) -> str: + """ + Convert an S3 URL to a CDN URL + + Example: + s3://bucket-name/path/to/file.txt -> https://cdn.example.com/path/to/file.txt + """ + if not is_s3_path(file_path): + raise ValueError(f"Expected s3:// path, got: {file_path}") + + return file_path.replace(s3_config.public_files_bucket_path, cdn_url) + + +def presign_or_s3_cdnify_url(file_path: str, s3_config: S3Config | None = None) -> str: + """ + Generates a URL for file download, either using CDN or pre-signed URL. + """ + if s3_config is None: + s3_config = get_default_s3_config() + + if s3_config.cdn_url: + return convert_public_s3_to_cdn_url(file_path, s3_config.cdn_url, s3_config) + else: + return pre_sign_file_location(file_path, s3_config) diff --git a/backend/grants_shared/src/grants_shared/util/json_util.py b/backend/grants_shared/src/grants_shared/util/json_util.py new file mode 100644 index 0000000..818874f --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/json_util.py @@ -0,0 +1,60 @@ +from collections.abc import Callable +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from typing import Any +from uuid import UUID + + +# identity returns an unmodified object +def identity[T](obj: T) -> T: + return obj + + +# Mapping of types to functions for conversion +# when writing logs to JSON +ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = { + # JSONEncoder handles these properly already: + # https://docs.python.org/3/library/json.html#json.JSONEncoder + str: identity, + int: identity, + float: identity, + bool: identity, + list: identity, + datetime: lambda d: d.isoformat(), + date: lambda d: d.isoformat(), + Enum: lambda e: e.value, + set: lambda s: list(s), + # The fallback below would do these, + # but making it explicit that these + # types are supported for logging. + Decimal: str, + UUID: str, + Exception: str, +} + + +def json_encoder(obj: Any) -> Any: + """ + Handle conversion of various types when logs + are serialized into JSON. If not specified + will attempt to convert using str() on the object + """ + + _type = type(obj) + encode = ENCODERS_BY_TYPE.get(_type, str) + + """ + The recommended approach from the JSON docs + is to call the default method from JSONEncoder + to allow it to error anything not defined, we + choose not to do that as we want to give a best + effort for every value to be serialized for the logs + https://docs.python.org/3/library/json.html + + If a field you are trying to log doesn't make sense + to format as a string then please add it above, but be + aware that the format needs to be parseable by whatever + tools you are using to ingest logs and metrics. + """ + return encode(obj) diff --git a/backend/grants_shared/src/grants_shared/util/local.py b/backend/grants_shared/src/grants_shared/util/local.py new file mode 100644 index 0000000..9f188fb --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/local.py @@ -0,0 +1,31 @@ +import logging +import os + +from dotenv import load_dotenv + +logger = logging.getLogger(__name__) + + +def load_local_env_vars(env_file: str = "local.env") -> None: + """ + Load environment variables from the local.env so + that they can be fetched with `os.getenv()` or with + other utils that pull env vars. + + https://pypi.org/project/python-dotenv/ + + NOTE: any existing env vars will not be overriden by this + """ + environment = os.getenv("ENVIRONMENT", None) + + # If the environment is explicitly local or undefined + # we'll use the dotenv file, otherwise we'll skip + # Should never run if not local development + if environment is None or environment == "local": + load_dotenv(env_file) + + +def error_if_not_local() -> None: + if (env := os.getenv("ENVIRONMENT")) != "local": + logger.error("Environment %s is not local - cannot run operation", env) + raise Exception("Local-only process called when environment was set to non-local") diff --git a/backend/grants_shared/src/grants_shared/util/string_utils.py b/backend/grants_shared/src/grants_shared/util/string_utils.py new file mode 100644 index 0000000..610de29 --- /dev/null +++ b/backend/grants_shared/src/grants_shared/util/string_utils.py @@ -0,0 +1,81 @@ +import uuid + +from bs4 import BeautifulSoup, NavigableString + + +def join_list(joining_list: list | None, join_txt: str = "\n") -> str: + """ + Utility to join a list. + + Functionally equivalent to: + "" if joining_list is None else "\n".join(joining_list) + """ + if not joining_list: + return "" + + return join_txt.join(joining_list) + + +def is_valid_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def truncate_html_inline(value: str, max_length: int, suffix: str) -> str: + """ + Truncate visible text inside HTML while preserving valid structure. + + - Only text nodes are truncated (never tags). + - HTML structure remains valid. + - The suffix is appended inline inside the last text node. + """ + if not value or len(value) <= max_length: + return value + + # Parse the HTML into a tree structure + soup = BeautifulSoup(value, "html.parser") + + total_length = 0 + reached_limit = False + + # Walk through all text nodes in document order + for text_node in soup.find_all(string=True): + + if reached_limit: + # Remove any remaining text after truncation point + text_node.extract() + continue + + text = str(text_node) + remaining = max_length - total_length + + if len(text) <= remaining: + # Entire text node fits within limit + total_length += len(text) + else: + # Truncate inside the text node + truncated_text = text[:remaining].rstrip() + + # Create new truncated text node + new_text_node = NavigableString(truncated_text) + + # Replace original text node with truncated one + text_node.replace_with(new_text_node) + + # Append suffix + suffix_fragment = BeautifulSoup(suffix, "html.parser") + new_text_node.insert_after(suffix_fragment) + + # Remove all remaining content after this point + node = suffix_fragment.next_sibling + while node: + next_node = node.next_sibling + node.extract() + node = next_node + + break + + return str(soup) diff --git a/backend/grants_shared/tests/__init__.py b/backend/grants_shared/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/__init__.py b/backend/grants_shared/tests/grants_shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/__init__.py b/backend/grants_shared/tests/grants_shared/adapters/aws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/test_api_gateway_adapter.py b/backend/grants_shared/tests/grants_shared/adapters/aws/test_api_gateway_adapter.py new file mode 100644 index 0000000..87cbe36 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/aws/test_api_gateway_adapter.py @@ -0,0 +1,312 @@ +from unittest.mock import Mock, patch + +import pytest + +from grants_shared.adapters.aws.api_gateway_adapter import ( + ApiGatewayConfig, + ApiKeyImportResponse, + _clear_mock_import_responses, + _get_mock_import_responses, + get_boto_api_gateway_client, + import_api_key, +) + + +class TestApiGatewayConfig: + """Test the configuration class for API Gateway.""" + + def test_config_defaults(self, monkeypatch): + """Test that configuration requires default_usage_plan_id to be set.""" + from pydantic import ValidationError + + # Clear any existing environment variable + monkeypatch.delenv("API_GATEWAY_DEFAULT_USAGE_PLAN_ID", raising=False) + + # Should raise a ValidationError when required field is missing + with pytest.raises(ValidationError): + ApiGatewayConfig() + + def test_config_from_environment(self, monkeypatch): + """Test that configuration can be loaded from environment variables.""" + monkeypatch.setenv("API_GATEWAY_DEFAULT_USAGE_PLAN_ID", "test-plan-123") + config = ApiGatewayConfig() + + assert config.default_usage_plan_id == "test-plan-123" + + +class TestGetBotoApiGatewayClient: + """Test the boto3 client factory function.""" + + @patch("grants_shared.adapters.aws.api_gateway_adapter.get_boto_session") + def test_uses_default_session(self, mock_get_session): + """Test that default session is used when none provided.""" + mock_session = Mock() + mock_get_session.return_value = mock_session + + get_boto_api_gateway_client() + + mock_get_session.assert_called_once() + mock_session.client.assert_called_once_with("apigateway") + + def test_uses_provided_session(self): + """Test that provided session is used.""" + mock_session = Mock() + + get_boto_api_gateway_client(session=mock_session) + + mock_session.client.assert_called_once_with("apigateway") + + +class TestImportApiKeyFunction: + """Test the main import_api_key function.""" + + def setup_method(self): + """Clear mock responses before each test.""" + _clear_mock_import_responses() + + @patch("grants_shared.adapters.aws.api_gateway_adapter.is_local_aws") + def test_local_mock_import(self, mock_is_local): + """Test that mock response is returned when running locally.""" + mock_is_local.return_value = True + + response = import_api_key( + api_key="test-key-12345", + name="Test API Key", + description="Test description", + enabled=True, + usage_plan_id="test-plan-123", + ) + + # Verify the response structure + assert response.name == "Test API Key" + assert response.description == "Test description" + assert response.enabled is True + assert response.id.startswith("mock-") + assert response.stage_keys == [] + assert response.tags == {} + + # Verify the mock response was stored + mock_responses = _get_mock_import_responses() + assert len(mock_responses) == 1 + + request_data, response_data = mock_responses[0] + assert request_data["api_key"] == "test-key-12345" + assert request_data["name"] == "Test API Key" + assert request_data["description"] == "Test description" + assert request_data["enabled"] is True + assert request_data["usage_plan_id"] == "test-plan-123" + + @patch("grants_shared.adapters.aws.api_gateway_adapter.is_local_aws") + @patch("grants_shared.adapters.aws.api_gateway_adapter.get_boto_api_gateway_client") + def test_real_aws_import_success(self, mock_get_client, mock_is_local): + """Test successful API key import with real AWS client.""" + mock_is_local.return_value = False + + # Mock the boto3 client responses + mock_boto_client = Mock() + mock_get_client.return_value = mock_boto_client + + # Mock the import_api_keys response + mock_boto_client.import_api_keys.return_value = {"ids": ["api-key-123"], "warnings": []} + + # Mock the get_api_key response + mock_boto_client.get_api_key.return_value = { + "id": "api-key-123", + "name": "Test API Key", + "description": "Test description", + "enabled": True, + "stageKeys": [], + "tags": {}, + } + + response = import_api_key( + api_key="test-key-12345", + name="Test API Key", + description="Test description", + enabled=True, + ) + + # Verify the boto3 calls were made correctly + expected_csv = "name,key,description,enabled,usageplanIds\nTest API Key,test-key-12345,Test description,true," + mock_boto_client.import_api_keys.assert_called_once_with( + body=expected_csv.encode("utf-8"), + format="csv", + failOnWarnings=True, + ) + mock_boto_client.get_api_key.assert_called_once_with( + apiKey="api-key-123", includeValue=False + ) + + # Verify the response + assert response.id == "api-key-123" + assert response.name == "Test API Key" + assert response.description == "Test description" + assert response.enabled is True + + @patch("grants_shared.adapters.aws.api_gateway_adapter.is_local_aws") + @patch("grants_shared.adapters.aws.api_gateway_adapter.get_boto_api_gateway_client") + def test_real_aws_import_with_usage_plan(self, mock_get_client, mock_is_local): + """Test API key import with usage plan association via CSV format.""" + mock_is_local.return_value = False + + mock_boto_client = Mock() + mock_get_client.return_value = mock_boto_client + + # Mock responses + mock_boto_client.import_api_keys.return_value = {"ids": ["api-key-123"], "warnings": []} + + mock_boto_client.get_api_key.return_value = { + "id": "api-key-123", + "name": "Test API Key", + "description": "Test description", + "enabled": True, + "stageKeys": [], + "tags": {}, + } + + response = import_api_key( + api_key="test-key-12345", + name="Test API Key", + description="Test description", + enabled=True, + usage_plan_id="test-plan-123", + ) + + # Verify the CSV format includes the usage plan ID + expected_csv = 'name,key,description,enabled,usageplanIds\nTest API Key,test-key-12345,Test description,true,"test-plan-123"' + mock_boto_client.import_api_keys.assert_called_once_with( + body=expected_csv.encode("utf-8"), + format="csv", + failOnWarnings=True, + ) + + # Verify no separate usage plan association call is made + mock_boto_client.create_usage_plan_key.assert_not_called() + + assert response.id == "api-key-123" + + @patch("grants_shared.adapters.aws.api_gateway_adapter.is_local_aws") + @patch("grants_shared.adapters.aws.api_gateway_adapter.get_boto_api_gateway_client") + def test_real_aws_import_with_warnings(self, mock_get_client, mock_is_local, caplog): + """Test API key import that generates warnings.""" + mock_is_local.return_value = False + + mock_boto_client = Mock() + mock_get_client.return_value = mock_boto_client + + # Mock response with warnings + mock_boto_client.import_api_keys.return_value = { + "ids": ["api-key-123"], + "warnings": ["Key already exists"], + } + + mock_boto_client.get_api_key.return_value = { + "id": "api-key-123", + "name": "Test API Key", + "description": None, + "enabled": True, + "stageKeys": [], + "tags": {}, + } + + response = import_api_key(api_key="test-key-12345", name="Test API Key", enabled=True) + + # Verify warning was logged + assert "API Gateway import warnings" in caplog.text + # Verify warning details are in the log records + warning_records = [ + record for record in caplog.records if "API Gateway import warnings" in record.message + ] + assert len(warning_records) > 0 + assert hasattr(warning_records[0], "warnings") + assert "Key already exists" in warning_records[0].warnings + + assert response.id == "api-key-123" + + @patch("grants_shared.adapters.aws.api_gateway_adapter.is_local_aws") + @patch("grants_shared.adapters.aws.api_gateway_adapter.get_boto_api_gateway_client") + def test_real_aws_import_no_key_ids_returned(self, mock_get_client, mock_is_local): + """Test error handling when no key IDs are returned.""" + mock_is_local.return_value = False + + mock_boto_client = Mock() + mock_get_client.return_value = mock_boto_client + + # Mock response with no IDs + mock_boto_client.import_api_keys.return_value = {"ids": [], "warnings": []} + + with pytest.raises(Exception, match="No API key IDs returned from import operation"): + import_api_key(api_key="test-key-12345", name="Test API Key", enabled=True) + + def test_csv_format_generation(self): + """Test that CSV format is generated correctly for different inputs.""" + # Test with all fields + with patch( + "grants_shared.adapters.aws.api_gateway_adapter.is_local_aws", return_value=True + ): + import_api_key( + api_key="test-key-123", + name="My API Key", + description="Key description", + enabled=True, + ) + + responses = _get_mock_import_responses() + assert len(responses) == 1 + + # Test with no description + _clear_mock_import_responses() + with patch( + "grants_shared.adapters.aws.api_gateway_adapter.is_local_aws", return_value=True + ): + import_api_key( + api_key="test-key-456", name="Another Key", description=None, enabled=False + ) + + responses = _get_mock_import_responses() + assert len(responses) == 1 + request_data, _ = responses[0] + assert request_data["description"] is None + assert request_data["enabled"] is False + + +class TestMockResponseHelpers: + """Test the mock response helper functions.""" + + def setup_method(self): + """Clear mock responses before each test.""" + _clear_mock_import_responses() + + def test_clear_mock_responses(self): + """Test that mock responses can be cleared.""" + # Add some mock responses + with patch( + "grants_shared.adapters.aws.api_gateway_adapter.is_local_aws", return_value=True + ): + import_api_key("key1", "name1") + import_api_key("key2", "name2") + + assert len(_get_mock_import_responses()) == 2 + + _clear_mock_import_responses() + + assert len(_get_mock_import_responses()) == 0 + + def test_get_mock_responses(self): + """Test that mock responses can be retrieved.""" + _clear_mock_import_responses() + + with patch( + "grants_shared.adapters.aws.api_gateway_adapter.is_local_aws", return_value=True + ): + import_api_key("test-key", "Test Name", "Test Description") + + responses = _get_mock_import_responses() + assert len(responses) == 1 + + request_data, response_data = responses[0] + assert request_data["api_key"] == "test-key" + assert request_data["name"] == "Test Name" + assert request_data["description"] == "Test Description" + assert isinstance(response_data, ApiKeyImportResponse) + assert response_data.name == "Test Name" diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/test_dynamodb_adapter.py b/backend/grants_shared/tests/grants_shared/adapters/aws/test_dynamodb_adapter.py new file mode 100644 index 0000000..c8ad1b8 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/aws/test_dynamodb_adapter.py @@ -0,0 +1,212 @@ +import logging +from unittest.mock import Mock, patch + +import boto3 +import pytest +from botocore.exceptions import ClientError +from pydantic import ValidationError + +from grants_shared.adapters.aws.dynamodb_adapter import ( + DynamoDBClient, + DynamoDBConfig, + DynamoDBGetItemResponse, + get_boto_dynamodb_client, +) + + +class TestDynamoDBConfig: + + def test_config_defaults(self, monkeypatch): + monkeypatch.delenv("FILE_SCAN_CACHE_TABLE_NAME", raising=False) + with pytest.raises(ValidationError): + DynamoDBConfig() + + def test_config_from_environment(self, monkeypatch): + monkeypatch.setenv("FILE_SCAN_CACHE_TABLE_NAME", "test-table") + config = DynamoDBConfig() + assert config.file_scan_cache_table_name == "test-table" + + +class TestGetBotoDynamoDBClient: + + @patch("grants_shared.adapters.aws.dynamodb_adapter.get_boto_session") + def test_uses_default_session(self, mock_get_session): + """Verify the case when no session is provided, use the default AWS session""" + mock_session = Mock() + mock_get_session.return_value = mock_session + get_boto_dynamodb_client() + mock_get_session.assert_called_once() + mock_session.client.assert_called_once_with( + "dynamodb", + region_name="us-east-1", + ) + + def test_uses_provided_session(self): + """Verify the case when a specific AWS session is provided.""" + mock_session = Mock() + get_boto_dynamodb_client(session=mock_session) + mock_session.client.assert_called_once_with( + "dynamodb", + region_name="us-east-1", + ) + + def test_uses_local_when_endpoint_url_set(self): + """Verify the case when the endpoint url is set""" + mock_session = Mock() + config = DynamoDBConfig(AWS_DYNAMODB_ENDPOINT_URL="http://example:8000") + get_boto_dynamodb_client(session=mock_session, dynamodb_config=config) + mock_session.client.assert_called_once_with( + "dynamodb", + region_name="us-east-1", + endpoint_url=config.aws_dynamodb_endpoint_url, + aws_access_key_id="local", + aws_secret_access_key="local", + ) + + +class TestDynamoDBClient: + + def test_get_item_success(self, file_scan_dynamodb_table): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + boto_client.put_item( + TableName=file_scan_dynamodb_table, + Item={ + "file_id": {"S": "test-id-123"}, + "user_id": {"S": "user-abc-123"}, + "status": {"S": "complete"}, + }, + ) + + response = dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="test-id-123", + ) + + assert isinstance(response, DynamoDBGetItemResponse) + assert response.item is not None + assert response.item["file_id"]["S"] == "test-id-123" + assert response.item["user_id"]["S"] == "user-abc-123" + assert response.item["status"]["S"] == "complete" + + def test_get_item_not_found(self, file_scan_dynamodb_table): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + response = dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="non-existent-id", + ) + + assert isinstance(response, DynamoDBGetItemResponse) + assert response.item is None + + def test_get_item_consistent_read_true(self, file_scan_dynamodb_table): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + boto_client.put_item( + TableName=file_scan_dynamodb_table, + Item={ + "file_id": {"S": "test-id-456"}, + "user_id": {"S": "user-456"}, + "status": {"S": "pending"}, + }, + ) + + response = dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="test-id-456", + consistent_read=True, + ) + + assert response.item is not None + assert response.item["file_id"]["S"] == "test-id-456" + assert response.item["status"]["S"] == "pending" + + def test_get_item_consistent_read_false(self, file_scan_dynamodb_table): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + boto_client.put_item( + TableName=file_scan_dynamodb_table, + Item={ + "file_id": {"S": "test-id-789"}, + "user_id": {"S": "user-789"}, + "status": {"S": "in_progress"}, + }, + ) + + response = dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="test-id-789", + consistent_read=False, + ) + + assert response.item is not None + assert response.item["file_id"]["S"] == "test-id-789" + assert response.item["status"]["S"] == "in_progress" + + def test_get_item_logs_on_error(self, mock_dynamodb, caplog): + invalid_table = "non-existent-table" + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + with pytest.raises(ClientError): + with caplog.at_level(logging.ERROR): + dynamodb_client.get_item( + table_name=invalid_table, + key_name="file_id", + value="test-id", + ) + + error_record = next( + r for r in caplog.records if r.message == "Failed to get item from DynamoDB" + ) + assert error_record.table_name == invalid_table + + def test_get_item_logs_success(self, file_scan_dynamodb_table, caplog): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + boto_client.put_item( + TableName=file_scan_dynamodb_table, + Item={ + "file_id": {"S": "test-id-999"}, + "user_id": {"S": "user-999"}, + "status": {"S": "infected"}, + }, + ) + + with caplog.at_level(logging.INFO): + dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="test-id-999", + ) + + success_record = next( + r for r in caplog.records if r.message == "Successfully retrieved item from DynamoDB" + ) + assert success_record.table_name == file_scan_dynamodb_table + + def test_get_item_logs_not_found(self, file_scan_dynamodb_table, caplog): + boto_client = boto3.client("dynamodb", region_name="us-east-1") + dynamodb_client = DynamoDBClient(dynamodb_client=boto_client) + + with caplog.at_level(logging.INFO): + dynamodb_client.get_item( + table_name=file_scan_dynamodb_table, + key_name="file_id", + value="missing-id", + ) + + not_found_record = next( + r for r in caplog.records if r.message == "Item not found in DynamoDB" + ) + assert not_found_record.table_name == file_scan_dynamodb_table diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_adapter.py b/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_adapter.py new file mode 100644 index 0000000..76ba48c --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_adapter.py @@ -0,0 +1,137 @@ +import os + +import pytest +from moto.core import DEFAULT_ACCOUNT_ID +from moto.ses.models import ses_backends + +import grants_shared.adapters.aws.aws_session as aws_session_module +from grants_shared.adapters.aws import send_email + + +def test_send_email_success(ses_client): + """Test successfully sending an email via SES""" + to_email = "recipient@example.com" + message_subject = "Test Subject" + message_body = "This is a test email message" + + message_id = send_email( + to_address=to_email, + subject=message_subject, + message=message_body, + ses_client=ses_client, + ) + + assert message_id is not None + ses_backend = ses_backends[DEFAULT_ACCOUNT_ID][ses_client.meta.region_name] + assert len(ses_backend.sent_messages) == 1 + + sent_email = ses_backend.sent_messages[0] + assert sent_email.source == os.getenv("AWS_SES_FROM_EMAIL") + assert to_email in sent_email.destinations["ToAddresses"] + assert sent_email.subject == message_subject + assert sent_email.body == message_body + + +def test_send_email_html_special_characters(ses_client): + """Test sending emails with HTML content and special characters in subject and body""" + to_email = "recipient@example.com" + subject = "Test with émojis 🎉 and spëcial çharacters" + message = """ + +

Test Email with Special Characters

+

Message with special characters: €, £, ¥, ñ, ü

+

Emojis: 🚀 📧 ✅ 🎉

+
    +
  • Currency: € £ ¥
  • +
  • Accents: é ñ ü ç
  • +
  • Symbols: © ® ™
  • +
+ + """ + + message_id = send_email( + to_address=to_email, + subject=subject, + message=message, + ses_client=ses_client, + ) + + assert message_id is not None + + # Verify special characters and HTML content are preserved correctly + ses_backend = ses_backends[DEFAULT_ACCOUNT_ID][ses_client.meta.region_name] + sent_email = ses_backend.sent_messages[0] + + # Verify subject with special characters + assert sent_email.subject == subject + assert "🎉" in sent_email.subject + assert "émojis" in sent_email.subject + + # Verify HTML content is preserved + assert sent_email.body == message + assert "" in sent_email.body + assert "

Test Email with Special Characters

" in sent_email.body + + # Verify special characters in HTML body + assert "€" in sent_email.body + assert "🚀" in sent_email.body + assert "ñ" in sent_email.body + assert "©" in sent_email.body + + +def test_send_email_unverified_sender(ses_client): + """Test that sending from an unverified email address fails""" + to_email = "recipient@example.com" + message_subject = "Test Subject" + message_body = "This is a test email message" + + # Delete the verified email identity to simulate unverified sender + ses_client.delete_email_identity(EmailIdentity=os.getenv("AWS_SES_FROM_EMAIL")) + + with pytest.raises(Exception) as exc_info: + send_email( + to_address=to_email, + subject=message_subject, + message=message_body, + ses_client=ses_client, + ) + + # "Email address not verified" is partial of the error message + assert "Email address not verified" in str(exc_info.value) + + +def test_send_email_multiple_recipients(ses_client): + """Test sending emails to multiple recipients sequentially""" + recipients = ["recipient1@example.com", "recipient2@example.com", "recipient3@example.com"] + message_subject = "Test Subject" + message_body = "This is a test email message" + + message_ids = [] + for recipient in recipients: + message_id = send_email( + to_address=recipient, + subject=message_subject, + message=message_body, + ses_client=ses_client, + ) + message_ids.append(message_id) + + ses_backend = ses_backends[DEFAULT_ACCOUNT_ID][ses_client.meta.region_name] + assert len(ses_backend.sent_messages) == len(recipients) + assert all(isinstance(msg.id, str) and len(msg.id) > 0 for msg in ses_backend.sent_messages) + + +def test_send_email_local_environment(monkeypatch): + """Test that in local environment, emails are not actually sent""" + monkeypatch.setenv("IS_LOCAL_AWS", "1") + + # Clear the cached config so it picks up the new environment variable + aws_session_module._aws_config = None + + message_id = send_email( + to_address="test@example.com", + subject="Local Test", + message="This should not actually send", + ) + + assert message_id == "local-mock-message-id" diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_suppressed_email_adapter.py b/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_suppressed_email_adapter.py new file mode 100644 index 0000000..a36ad37 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/aws/test_ses_suppressed_email_adapter.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from grants_shared.adapters.aws.ses_suppressed_email_adapter import ( + MockSESV2Client, + SuppressedDestination, +) + + +def test_ses_adapter(): + client = MockSESV2Client() + + client.add_mock_responses( + SuppressedDestination( + EmailAddress="bounce@simulator.amazonses.com", + Reason="BOUNCE", + LastUpdateTime=datetime(2020, 1, 1, 12, 0, 0), + ) + ) + client.add_mock_responses( + SuppressedDestination( + EmailAddress="bounce2@simulator.amazonses.com", + Reason="COMPLAINT", + LastUpdateTime=datetime(2025, 1, 1, 9, 30, 0), + ) + ) + + resp = client.list_suppressed_destinations() + + assert len(resp.suppressed_destination_summaries) == 2 + + assert client.mock_responses[0].email_address == "bounce@simulator.amazonses.com" + assert client.mock_responses[0].reason == "BOUNCE" + assert client.mock_responses[0].last_update_time == datetime(2020, 1, 1, 12, 0, 0) + + assert client.mock_responses[1].email_address == "bounce2@simulator.amazonses.com" + assert client.mock_responses[1].reason == "COMPLAINT" + assert client.mock_responses[1].last_update_time == datetime(2025, 1, 1, 9, 30, 0) + + resp = client.list_suppressed_destinations(start_time=datetime(2022, 1, 1)) + data = resp.suppressed_destination_summaries + + assert data[0].email_address == "bounce2@simulator.amazonses.com" + assert data[0].reason == "COMPLAINT" + assert data[0].last_update_time == datetime(2025, 1, 1, 9, 30, 0) diff --git a/backend/grants_shared/tests/grants_shared/adapters/aws/test_sqs_adapter.py b/backend/grants_shared/tests/grants_shared/adapters/aws/test_sqs_adapter.py new file mode 100644 index 0000000..687afed --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/aws/test_sqs_adapter.py @@ -0,0 +1,223 @@ +import json +import logging +from unittest.mock import Mock, patch + +import boto3 +import pytest +from botocore.exceptions import ClientError +from pydantic import ValidationError + +from grants_shared.adapters.aws.sqs_adapter import ( + SQSClient, + SQSConfig, + SQSDeleteBatchResponse, + SQSSendMessageResponse, + get_boto_sqs_client, +) + + +class TestSQSConfig: + """Tests for the SQS configuration class.""" + + def test_config_defaults(self, monkeypatch): + """Verify that SQSConfig raises a validation error if required environment variables are missing.""" + monkeypatch.delenv("WORKFLOW_QUEUE_URL", raising=False) + with pytest.raises(ValidationError): + SQSConfig() + + def test_config_from_environment(self, monkeypatch): + """Verify that SQSConfig correctly loads the queue URL from environment variables.""" + monkeypatch.setenv("WORKFLOW_QUEUE_URL", "https://test-queue-url") + config = SQSConfig() + assert config.workflow_queue_url == "https://test-queue-url" + + +class TestGetBotoSQSClient: + """Tests for the SQS boto3 client factory function.""" + + @patch("grants_shared.adapters.aws.sqs_adapter.get_boto_session") + def test_uses_default_session(self, mock_get_session, workflow_sqs_queue): + """Verify that the factory function uses the default AWS session when none is provided.""" + mock_session = Mock() + mock_get_session.return_value = mock_session + get_boto_sqs_client() + mock_get_session.assert_called_once() + mock_session.client.assert_called_once_with("sqs", region_name="us-east-1") + + def test_uses_provided_session(self, workflow_sqs_queue): + """Verify that the factory function uses a specifically provided AWS session.""" + mock_session = Mock() + get_boto_sqs_client(session=mock_session) + mock_session.client.assert_called_once_with("sqs", region_name="us-east-1") + + def test_uses_local_when_endpoint_url_set(self, workflow_sqs_queue): + """Verify the case when the endpoint url is set""" + mock_session = Mock() + config = SQSConfig(AWS_SQS_ENDPOINT_URL="http://example:8000") + get_boto_sqs_client(session=mock_session, sqs_config=config) + mock_session.client.assert_called_once_with( + "sqs", region_name="us-east-1", endpoint_url=config.aws_sqs_endpoint_url + ) + + +class TestSQSClient: + """Tests for the SQSClient adapter methods.""" + + def test_receive_messages_success(self, workflow_sqs_queue): + """Verify that receive_messages successfully retrieves and parses messages as SQSMessage objects.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + boto_client.send_message( + QueueUrl=workflow_sqs_queue, MessageBody='{"event_type": "start_workflow"}' + ) + + messages = sqs_client.receive_messages(max_messages=1) + assert len(messages) == 1 + assert messages[0].body == '{"event_type": "start_workflow"}' + + def test_receive_messages_empty_queue(self, workflow_sqs_queue): + """Verify that receive_messages returns an empty list when no messages are available.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + messages = sqs_client.receive_messages(max_messages=10, wait_time=0) + assert messages == [] + + def test_receive_messages_logs_on_error(self, mock_sqs, caplog): + """Verify that failed receive attempts raise a ClientError and log the queue URL as extra context.""" + invalid_url = "https://sqs.us-east-1.amazonaws.com/123456789012/non-existent" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=invalid_url, sqs_client=boto_client) + + with pytest.raises(ClientError): + with caplog.at_level(logging.ERROR): + sqs_client.receive_messages() + + assert "Failed to receive messages from SQS" in caplog.text + sqs_record = next( + r for r in caplog.records if r.message == "Failed to receive messages from SQS" + ) + assert sqs_record.queue_url == invalid_url + + def test_visibility_timeout_hides_message(self, workflow_sqs_queue): + """Verify that a message becomes invisible to subsequent requests for the duration of the visibility timeout.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + boto_client.send_message(QueueUrl=workflow_sqs_queue, MessageBody="hidden-test") + + sqs_client.receive_messages(max_messages=1, visibility_timeout=2) + second_attempt = sqs_client.receive_messages(max_messages=1, wait_time=0) + assert len(second_attempt) == 0 + + def test_delete_message_batch_success(self, workflow_sqs_queue): + """Verify that delete_message_batch removes multiple messages and returns an SQSDeleteBatchResponse.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + boto_client.send_message(QueueUrl=workflow_sqs_queue, MessageBody="msg1") + boto_client.send_message(QueueUrl=workflow_sqs_queue, MessageBody="msg2") + + messages = sqs_client.receive_messages(max_messages=2) + handles = [m.receipt_handle for m in messages] + results = sqs_client.delete_message_batch(handles) + + assert isinstance(results, SQSDeleteBatchResponse) + assert len(results.successful_deletes) == 2 + assert set(handles) == results.successful_deletes + + def test_delete_message_batch_partial_failure(self, workflow_sqs_queue): + """Verify that delete_message_batch correctly reports success and failure sets when only some deletions succeed.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + boto_client.send_message(QueueUrl=workflow_sqs_queue, MessageBody="valid-msg") + + messages = sqs_client.receive_messages(max_messages=1) + valid_handle = messages[0].receipt_handle + invalid_handle = "this-handle-does-not-exist" + + results = sqs_client.delete_message_batch([valid_handle, invalid_handle]) + + assert valid_handle in results.successful_deletes + assert invalid_handle in results.failed_deletes + + def test_send_message_success(self, workflow_sqs_queue): + """Verify that send_message successfully sends a message and returns an SQSSendMessageResponse.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + message_body = {"event_type": "workflow_started", "workflow_id": "12345"} + response = sqs_client.send_message(message_body) + + assert isinstance(response, SQSSendMessageResponse) + assert response.message_id is not None + assert response.md5_of_message_body is not None + assert len(response.message_id) > 0 + assert len(response.md5_of_message_body) == 32 # MD5 hash is 32 characters + + def test_send_message_returns_all_response_fields(self, workflow_sqs_queue): + """Verify that send_message response includes all expected fields from boto3.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + message_body = {"test": "data"} + response = sqs_client.send_message(message_body) + + # Required fields + assert hasattr(response, "message_id") + assert hasattr(response, "md5_of_message_body") + # Optional fields (may be None for standard queues) + assert hasattr(response, "md5_of_message_attributes") + assert hasattr(response, "sequence_number") + assert hasattr(response, "md5_of_message_system_attributes") + + def test_send_message_can_be_received(self, workflow_sqs_queue): + """Verify that a message sent via send_message can be successfully received.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + message_body = {"event_type": "test_event", "data": {"key": "value"}} + send_response = sqs_client.send_message(message_body) + + messages = sqs_client.receive_messages(max_messages=1, wait_time=0) + + assert len(messages) == 1 + assert messages[0].message_id == send_response.message_id + + received_body = json.loads(messages[0].body) + assert received_body == message_body + + def test_send_message_logs_on_error(self, mock_sqs, caplog): + """Verify that failed send attempts raise a ClientError and log the queue URL as extra context.""" + invalid_url = "https://sqs.us-east-1.amazonaws.com/123456789012/non-existent" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=invalid_url, sqs_client=boto_client) + + message_body = {"test": "data"} + + with pytest.raises(ClientError): + with caplog.at_level(logging.ERROR): + sqs_client.send_message(message_body) + + assert "Failed to send message to SQS" in caplog.text + sqs_record = next(r for r in caplog.records if r.message == "Failed to send message to SQS") + assert sqs_record.queue_url == invalid_url + + def test_send_message_logs_success(self, workflow_sqs_queue, caplog): + """Verify that successful message sends are logged with the message ID.""" + boto_client = boto3.client("sqs", region_name="us-east-1") + sqs_client = SQSClient(queue_url=workflow_sqs_queue, sqs_client=boto_client) + + message_body = {"event_type": "test"} + + with caplog.at_level(logging.INFO): + response = sqs_client.send_message(message_body) + + assert "Successfully sent message to SQS" in caplog.text + success_record = next( + r for r in caplog.records if r.message == "Successfully sent message to SQS" + ) + assert success_record.queue_url == workflow_sqs_queue + assert success_record.message_id == response.message_id diff --git a/backend/grants_shared/tests/grants_shared/adapters/db/clients/__init__.py b/backend/grants_shared/tests/grants_shared/adapters/db/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/adapters/db/clients/test_postgres_client.py b/backend/grants_shared/tests/grants_shared/adapters/db/clients/test_postgres_client.py new file mode 100644 index 0000000..c50e1fa --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/db/clients/test_postgres_client.py @@ -0,0 +1,53 @@ +import logging +from dataclasses import dataclass + +import pytest + +from grants_shared.adapters.db.clients.postgres_client import get_connection_parameters, verify_ssl +from grants_shared.adapters.db.clients.postgres_config import get_db_config + + +@dataclass +class DummyPgConn: + ssl_in_use: bool + + +class DummyConnectionInfo: + def __init__(self, ssl_in_use): + self.pgconn = DummyPgConn(ssl_in_use) + + +def test_verify_ssl(caplog): + caplog.set_level(logging.INFO) + + conn_info = DummyConnectionInfo(True) + verify_ssl(conn_info) + + assert caplog.messages == ["database connection is using SSL"] + assert caplog.records[0].levelname == "INFO" + + +def test_verify_ssl_not_in_use(caplog): + caplog.set_level(logging.INFO) + + conn_info = DummyConnectionInfo(False) + verify_ssl(conn_info) + + assert caplog.messages == ["database connection is not using SSL"] + assert caplog.records[0].levelname == "WARNING" + + +def test_get_connection_parameters(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DB_SSL_MODE") + db_config = get_db_config() + conn_params = get_connection_parameters(db_config) + + assert conn_params == dict( + host=db_config.host, + dbname=db_config.name, + user=db_config.username, + password=db_config.password, + port=db_config.port, + connect_timeout=10, + sslmode="require", + ) diff --git a/backend/grants_shared/tests/grants_shared/adapters/db/test_db.py b/backend/grants_shared/tests/grants_shared/adapters/db/test_db.py new file mode 100644 index 0000000..d7a7d2d --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/db/test_db.py @@ -0,0 +1,23 @@ +import pytest +from sqlalchemy import text + +import grants_shared.adapters.db as db + + +def test_db_connection(db_client): + db_client = db.PostgresDBClient() + with db_client.get_connection() as conn: + assert conn.scalar(text("SELECT 1")) == 1 + + +def test_check_db_connection(caplog, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("DB_CHECK_CONNECTION_ON_INIT", "True") + db.PostgresDBClient() + assert "database connection is not using SSL" in caplog.messages + + +def test_get_session(): + db_client = db.PostgresDBClient() + with db_client.get_session() as session: + with session.begin(): + assert session.scalar(text("SELECT 1")) == 1 diff --git a/backend/grants_shared/tests/grants_shared/adapters/db/test_flask_db.py b/backend/grants_shared/tests/grants_shared/adapters/db/test_flask_db.py new file mode 100644 index 0000000..6325a5c --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/db/test_flask_db.py @@ -0,0 +1,51 @@ +import pytest +from flask import Flask, current_app +from sqlalchemy import text + +import grants_shared.adapters.db as db +import grants_shared.adapters.db.flask_db as flask_db + + +# Define an isolated example Flask app fixture specific to this test module +# to avoid dependencies on any project-specific fixtures in conftest.py +@pytest.fixture +def example_app() -> Flask: + app = Flask(__name__) + db_client = db.PostgresDBClient() + flask_db.register_db_client(db_client, app) + return app + + +def test_get_db(example_app: Flask): + @example_app.route("/hello") + def hello(): + with flask_db.get_db(current_app).get_connection() as conn: + return {"data": conn.scalar(text("SELECT 'hello, world'"))} + + response = example_app.test_client().get("/hello") + assert response.get_json() == {"data": "hello, world"} + + +def test_with_db_session(example_app: Flask): + @example_app.route("/hello") + @flask_db.with_db_session() + def hello(db_session: db.Session): + with db_session.begin(): + return {"data": db_session.scalar(text("SELECT 'hello, world'"))} + + response = example_app.test_client().get("/hello") + assert response.get_json() == {"data": "hello, world"} + + +def test_with_db_session_not_default_name(example_app: Flask): + db_client = db.PostgresDBClient() + flask_db.register_db_client(db_client, example_app, client_name="something_else") + + @example_app.route("/hello") + @flask_db.with_db_session(client_name="something_else") + def hello(db_session: db.Session): + with db_session.begin(): + return {"data": db_session.scalar(text("SELECT 'hello, world'"))} + + response = example_app.test_client().get("/hello") + assert response.get_json() == {"data": "hello, world"} diff --git a/backend/grants_shared/tests/grants_shared/adapters/oauth/__init__.py b/backend/grants_shared/tests/grants_shared/adapters/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/adapters/oauth/login_gov/__init__.py b/backend/grants_shared/tests/grants_shared/adapters/oauth/login_gov/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/adapters/oauth/login_gov/test_login_gov_oauth_client.py b/backend/grants_shared/tests/grants_shared/adapters/oauth/login_gov/test_login_gov_oauth_client.py new file mode 100644 index 0000000..30348cf --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/adapters/oauth/login_gov/test_login_gov_oauth_client.py @@ -0,0 +1,53 @@ +import json + +import requests + +from grants_shared.adapters.oauth.login_gov.login_gov_oauth_client import LoginGovOauthClient +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenRequest + + +def mock_response(monkeypatch, mocked_response: dict): + def mock_post(*args, **kwargs): + response = requests.Response() + # _content is fetched by the text method which we use when deserializing + response._content = bytes(json.dumps(mocked_response), "utf-8") + return response + + monkeypatch.setattr("requests.Session.request", mock_post) + + +def test_get_token(monkeypatch, login_gov_config): + + mock_response( + monkeypatch, + {"id_token": "abc123", "access_token": "xyz456", "token_type": "Bearer", "expires_in": 300}, + ) + + client = LoginGovOauthClient(login_gov_config) + resp = client.get_token(OauthTokenRequest(code="abc123", client_assertion="fake_token")) + + assert resp.id_token == "abc123" + assert resp.access_token == "xyz456" + assert resp.token_type == "Bearer" + assert resp.expires_in == 300 + assert resp.error is None + assert resp.error_description is None + assert resp.is_error_response() is False + + +def test_get_token_error(monkeypatch, login_gov_config): + mock_response( + monkeypatch, + {"error": "invalid_request", "error_description": "missing required parameter grant_type"}, + ) + + client = LoginGovOauthClient(login_gov_config) + resp = client.get_token(OauthTokenRequest(code="abc123", client_assertion="fake_token")) + + assert resp.id_token == "" + assert resp.access_token == "" + assert resp.token_type == "" + assert resp.expires_in == 0 + assert resp.error == "invalid_request" + assert resp.error_description == "missing required parameter grant_type" + assert resp.is_error_response() is True diff --git a/backend/grants_shared/tests/grants_shared/api/__init__.py b/backend/grants_shared/tests/grants_shared/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/__init__.py b/backend/grants_shared/tests/grants_shared/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/extension/__init__.py b/backend/grants_shared/tests/grants_shared/api/schemas/extension/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/extension/test_schema_fields.py b/backend/grants_shared/tests/grants_shared/api/schemas/extension/test_schema_fields.py new file mode 100644 index 0000000..314f51c --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/api/schemas/extension/test_schema_fields.py @@ -0,0 +1,92 @@ +import inspect + +import pytest +from marshmallow import ValidationError + +from grants_shared.api.schemas.extension import fields +from tests.grants_shared.api.schemas.schema_validation_utils import ( + DummySchema, + EnumA, + EnumB, + FieldTestSchema, + get_expected_validation_errors, + get_invalid_field_test_schema_req, + get_valid_field_test_schema_req, + validate_errors, +) + + +def test_enum_field(): + schema = DummySchema() + + both_ab_field = schema.declared_fields["both_ab"] + + # Make sure the multi enum can deserialize to both enums and reserialize to a string + for e in EnumA: + deserialized_value = both_ab_field._deserialize(str(e), None, None) + assert deserialized_value == e + assert isinstance(deserialized_value, EnumA) + + serialized_value = both_ab_field._serialize(e, None, None) + assert isinstance(serialized_value, str) + for e in EnumB: + deserialized_value = both_ab_field._deserialize(str(e), None, None) + assert deserialized_value == e + assert isinstance(deserialized_value, EnumB) + + serialized_value = both_ab_field._serialize(e, None, None) + assert isinstance(serialized_value, str) + + with pytest.raises( + ValidationError, match="Must be one of: value1, value2, value3, value4, value5, value6." + ): + both_ab_field._deserialize("not_a_value", None, None) + + with pytest.raises( + ValidationError, match="Must be one of: value1, value2, value3, value4, value5, value6." + ): + both_ab_field._deserialize({}, None, None) + + +@pytest.mark.parametrize( + "payload,expected_errors", + [(get_invalid_field_test_schema_req(), get_expected_validation_errors())], +) +def test_fields(payload, expected_errors): + errors = FieldTestSchema().validate(payload) + validate_errors(errors, expected_errors) + + +def test_fields_ignore_unknowns(): + unknown_key = "UNKNOWN" + payload = {**get_valid_field_test_schema_req(), unknown_key: "EXCLUDED"} + result = FieldTestSchema().load(payload) + assert unknown_key not in result + + +def test_fields_configured_properly(): + """ + This is a sanity-test to verify we have properly + overriden and defined all the default error codes + that Marshmallow uses. + + If you see this test failing after updating our + dependency on Marshmallow, likely just need to add + a configuration to the relevant class' "error_mapping" object + """ + relevant_classes = [] + for _, obj in inspect.getmembers(fields): + if inspect.isclass(obj) and issubclass(obj, fields.MixinField): + relevant_classes.append(obj) + + for relevant_class in relevant_classes: + if relevant_class == fields.Enum: + # We don't derive from the original and made a custom enum field + # so the default error messages aren't relevant + assert relevant_class.error_mapping.keys() == {"unknown"} + continue + + # We want to make sure all keys are configured, but we also may have more + required_error_message_keys = relevant_class.default_error_messages.keys() + configured_error_message_keys = relevant_class.error_mapping.keys() + assert configured_error_message_keys >= required_error_message_keys diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py b/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py new file mode 100644 index 0000000..4476563 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py @@ -0,0 +1,478 @@ +from enum import Enum, StrEnum +from random import choice +from string import ascii_uppercase + +from grants_shared.api.schemas.extension import ( + MarshmallowErrorContainer, + Schema, + SchemaValidationError, + fields, + validators, +) +from grants_shared.util import dict_util + +############################# +# Validation Error Messages +############################# +MISSING_DATA = MarshmallowErrorContainer( + SchemaValidationError.REQUIRED, "Missing data for required field." +) +INVALID_INTEGER = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid integer.") +INVALID_INTEGER_32BIT = MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_VALUE, + "Must be greater than or equal to -2147483648 and less than or equal to 2147483647.", +) +INVALID_STRING = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid string.") +INVALID_STRING_PATTERN = MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "String does not match expected pattern." +) +INVALID_DATE = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid date.") +INVALID_DATETIME = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid datetime.") +INVALID_TIME = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid time.") +INVALID_URL = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid URL.") +INVALID_FLOAT = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid number.") +INVALID_SPECIAL_FLOAT = MarshmallowErrorContainer( + SchemaValidationError.SPECIAL_NUMERIC, + "Special numeric values (nan or infinity) are not permitted.", +) +INVALID_BOOLEAN = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid boolean.") +INVALID_SCHEMA_MSG = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Invalid input type.") +INVALID_SCHEMA = {"_schema": [INVALID_SCHEMA_MSG]} +INVALID_LIST = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid list.") +INVALID_UUID = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid UUID.") +INVALID_DECIMAL = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid decimal.") +INVALID_SPECIAL_DECIMAL = MarshmallowErrorContainer( + SchemaValidationError.SPECIAL_NUMERIC, + "Special numeric values (nan or infinity) are not permitted.", +) +INVALID_EMAIL = MarshmallowErrorContainer( + SchemaValidationError.FORMAT, "Not a valid email address." +) +INVALID_FILE = MarshmallowErrorContainer(SchemaValidationError.INVALID, "Not a valid file.") +UNKNOWN_FIELD = MarshmallowErrorContainer(SchemaValidationError.UNKNOWN, "Unknown field.") + + +######################## +# Validation Utilities +######################## +def get_random_string(length: int): + return "".join(choice(ascii_uppercase) for i in range(length)) + + +def get_enum_error_msg(*enums: type[Enum]): + possible_values = [] + for enum in enums: + possible_values.extend([e.value for e in enum]) + + return MarshmallowErrorContainer( + SchemaValidationError.INVALID_CHOICE, f"Must be one of: {', '.join(possible_values)}." + ) + + +def get_one_of_error_msg(choices: list[str]): + choices_text = ", ".join([c for c in choices]) + + return MarshmallowErrorContainer( + SchemaValidationError.INVALID_CHOICE, f"Value must be one of: {choices_text}" + ) + + +def get_min_length_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_LENGTH, f"Shorter than minimum length {length}." + ) + + +def get_max_length_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MAX_LENGTH, f"Longer than maximum length {length}." + ) + + +def get_length_range_error_msg(min: int, max: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_LENGTH, f"Length must be between {min} and {max}." + ) + + +def get_length_equal_error_msg(equal: int): + return MarshmallowErrorContainer(SchemaValidationError.EQUALS, f"Length must be {equal}.") + + +def get_min_value_error_msg(min: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_VALUE, f"Must be greater than or equal to {min}." + ) + + +def get_max_value_error_msg(max: int): + return MarshmallowErrorContainer( + SchemaValidationError.MAX_VALUE, f"Must be less than or equal to {max}." + ) + + +def get_max_or_min_value_error_msg(min: int = -2147483648, max: int = 2147483647): + # defaults are the 32-bit integer min/max + return MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_VALUE, + f"Must be greater than or equal to {min} and less than or equal to {max}.", + ) + + +def validate_errors(actual_errors, expected_errors): + assert len(actual_errors) == len( + expected_errors + ), f"Expected {len(expected_errors)}, but had {len(actual_errors)} errors" + for field_name in actual_errors: + assert field_name in expected_errors, f"{field_name} in errors but not expected" + assert ( + expected_errors[field_name] == actual_errors[field_name] + ), f"Actual error for {field_name}: {str(actual_errors[field_name])} but received {str(expected_errors[field_name])}" + + +def validate_expected_errors(issues: dict, expected_errors: dict[str, str]): + """ + Validate that the expected errors are present for each field when validating + against a Marshmallow schema. + + Use as: + + expected_errors = {"path.to.field": "validation_error"} + + issues = MySchema().validate({ ... }) + validate_expected_errors(issues, expected_errors) + + """ + flattened_issues = dict_util.flatten_dict(issues) + + assert len(flattened_issues) == len( + expected_errors + ), f"{','.join(flattened_issues)} != {','.join(expected_errors)}" + + validation_issues = [] + for k, expected_error in expected_errors.items(): + actual_error = flattened_issues.get(k, None) + + if actual_error is not None: + err = actual_error[0].key + + if err != expected_error: + validation_issues.append(f"Field {k} does not match. {err} != {expected_error}") + else: + validation_issues.append( + f"Field {k} not present in actual errors. The following are {', '.join(flattened_issues)}" + ) + + assert len(validation_issues) == 0, "\n".join(validation_issues) + + +######################## +# Schemas for testing +######################## + + +class EnumA(StrEnum): + VALUE1 = "value1" + VALUE2 = "value2" + VALUE3 = "value3" + + +class EnumB(StrEnum): + VALUE4 = "value4" + VALUE5 = "value5" + VALUE6 = "value6" + + +class DummySchema(Schema): + both_ab = fields.Enum(EnumA, EnumB) + + +class InnerTestSchema(Schema): + inner_str = fields.String() + inner_required_str = fields.String(required=True) + + +class FieldTestSchema(Schema): + field_str = fields.String() + field_str_required = fields.String(required=True) + field_str_min = fields.String(validate=[validators.Length(min=2)]) + field_str_max = fields.String(validate=[validators.Length(max=3)]) + field_str_min_and_max = fields.String(validate=[validators.Length(min=2, max=3)]) + field_str_equal = fields.String(validate=[validators.Length(equal=3)]) + field_str_regex = fields.String(validate=[validators.Regexp("^\\d{3}$")]) + field_str_regex_msg = fields.String( + validate=[validators.Regexp("^\\d{3}$", error_message="This is the override error")] + ) + field_str_email = fields.String(validate=[validators.Email()]) + field_str_one_of = fields.String(validate=[validators.OneOf(["a", "b"])]) + + field_url = fields.URL() + + field_int = fields.Integer() + field_int_required = fields.Integer(required=True) + field_int_strict = fields.Integer(strict=True) + field_int_32bit = fields.Integer(restrict_to_32bit_int=True) + field_int_min = fields.Integer(validate=[validators.Range(min=10)]) + field_int_max = fields.Integer(validate=[validators.Range(max=99)]) + field_int_min_max = fields.Integer(validate=[validators.Range(min=40, max=60)]) + field_int_min_max_with_32bit = fields.Integer( + restrict_to_32bit_int=True, validate=[validators.Range(min=1, max=10)] + ) + + field_bool = fields.Boolean() + field_bool_required = fields.Boolean(required=True) + + field_decimal = fields.Decimal() + field_decimal_required = fields.Decimal(required=True) + field_decimal_special = fields.Decimal(allow_nan=False) + + field_float = fields.Float() + field_float_required = fields.Float(required=True) + field_float_special = fields.Float(allow_nan=False) + + field_uuid = fields.UUID() + field_uuid_required = fields.UUID(required=True) + + field_date = fields.Date() + field_date_required = fields.Date(required=True) + field_date_format = fields.Date(format="iso8601") + + field_datetime = fields.DateTime() + field_datetime_required = fields.DateTime(required=True) + field_datetime_format = fields.DateTime(format="iso8601") + + field_time = fields.Time() + field_time_required = fields.Time(required=True) + field_time_format = fields.Time(format="%H:%M:%S") + + field_list = fields.List(fields.Boolean()) + field_list_required = fields.List(fields.Integer(), required=True) + field_list_indexed = fields.List(fields.Integer()) + + field_nested = fields.Nested(InnerTestSchema()) + field_nested_invalid = fields.Nested(InnerTestSchema()) + field_nested_required = fields.Nested(InnerTestSchema(), required=True) + + field_list_nested = fields.List(fields.Nested(InnerTestSchema())) + field_list_nested_invalid = fields.List(fields.Nested(InnerTestSchema())) + field_list_nested_required = fields.List(fields.Nested(InnerTestSchema()), required=True) + + # There's no "invalid" raw field it doesn't serialize/deserialize + field_raw_required = fields.Raw(required=True) + + field_enum = fields.Enum(EnumA) + field_enum_invalid_choice = fields.Enum(EnumA) + field_enum_invalid_type = fields.Enum(EnumA) + field_enum_required = fields.Enum(EnumB, required=True) + + # These tests verify JSON can be submitted, but these file + # fields are only usable as a form, so allow them to be None + # so we can set something to at least test the error scenarios. + field_file = fields.File(allow_none=True) + field_file_required = fields.File(required=True, allow_none=True) + + +######################## +# Requests for the above schema +######################## + + +def get_valid_field_test_schema_req(): + return { + "field_str": "text", + "field_str_required": "text", + "field_str_min": "abcd", + "field_str_max": "a", + "field_str_min_and_max": "ab", + "field_str_equal": "abc", + "field_str_regex": "123", + "field_str_regex_msg": "123", + "field_str_email": "person@example.com", + "field_str_one_of": "a", + "field_url": "https://example.com", + "field_int": 1, + "field_int_required": 2, + "field_int_strict": 3, + "field_int_32bit": 4, + "field_int_min": 25, + "field_int_max": 40, + "field_int_min_max": 50, + "field_int_min_max_with_32bit": 5, + "field_bool": True, + "field_bool_required": False, + "field_decimal": "2.5", + "field_decimal_required": "555", + "field_decimal_special": "4", + "field_float": 3.14, + "field_float_required": 2.71, + "field_float_special": 1.41, + "field_uuid": "1234a5b6-7c8d-90ef-1ab2-c3d45678e9f0", + "field_uuid_required": "1234a5b6-7c8d-90ef-1ab2-c3d45678e9f0", + "field_date": "2000-01-01", + "field_date_required": "2010-02-02", + "field_date_format": "2020-03-03", + "field_datetime": "2000-01-01T00:01:01Z", + "field_datetime_required": "2010-02-02T00:02:02Z", + "field_datetime_format": "2020-03-03T00:03:03Z", + "field_time": "14:30:00", + "field_time_required": "09:15:30", + "field_time_format": "16:45:00", + "field_list": [True], + "field_list_required": [], + "field_list_indexed": [1, 2, 3], + "field_nested": { + "inner_str": "text", + "inner_required_str": "text", + }, + "field_nested_invalid": { + "inner_str": "text", + "inner_required_str": "text", + }, + "field_nested_required": {"inner_str": "text", "inner_required_str": "present"}, + "field_list_nested": [ + {"inner_str": "text", "inner_required_str": "present"}, + {"inner_str": "text", "inner_required_str": "present"}, + ], + "field_list_nested_invalid": [], + "field_list_nested_required": [], + "field_raw_required": {}, + "field_enum": EnumA.VALUE1, + "field_enum_invalid_choice": EnumA.VALUE2, + "field_enum_required": EnumB.VALUE4, + # We cannot pass the file stream in as JSON, so the only + # valid values for files are None + "field_file": None, + "field_file_required": None, + } + + +def get_invalid_field_test_schema_req(): + return { + "field_str": 1234, + # field_str_required not present + "field_str_min": "a", + "field_str_max": "abcdef", + "field_str_min_and_max": "a", + "field_str_equal": "a", + "field_str_regex": "abc", + "field_str_regex_msg": "abc", + "field_str_email": "not an email", + "field_str_one_of": "hello", + "field_url": "not a url", + "field_int": {}, + # field_int_required not present + "field_int_strict": "123", + "field_int_32bit": 1_000_000_000_000_000, + "field_int_min": 1, + "field_int_max": 255, + "field_int_min_max": 100, + "field_int_min_max_with_32bit": 55, + "field_bool": 1234, + # field_bool_required not present + "field_decimal": "hello", + # field_decimal_required not present + "field_decimal_special": "NaN", + "field_float": "not a number", + # field_float_required not present + "field_float_special": "inf", + "field_uuid": "hello", + # field_uuid_required not present + "field_date": 1234, + # field_date_required not present + "field_date_format": "20220202020202", + "field_datetime": 1234, + # field_datetime_required not present + "field_datetime_format": "02022020 7-20PM PDT", + "field_time": 1234, + # field_time_required not present + "field_time_format": "not a time", + "field_list": "not_a_list", + # field_list_required not present + "field_list_indexed": ["text", 1, "text"], + "field_nested": { + "inner_str": 1234, + # inner_required_str not present + }, + "field_nested_invalid": 5678, + # field_nested_required not present + "field_list_nested": [ + {"inner_str": 5678, "inner_required_str": "present"}, + {"inner_str": "valid"}, # inner_required_str not present + 54321, + ], + "field_list_nested_invalid": 54321, + # field_list_nested_required not present + # field_raw_required not present + "field_enum": 12345, + "field_enum_invalid_choice": "notvalid", + "field_enum_invalid_type": {}, + "field_file": 10, + # field_file_required not present + } + + +def get_expected_validation_errors(): + # This is the expected output of the above + # get_invalid_field_test_schema_req function + return { + "field_str": [INVALID_STRING], + "field_str_required": [MISSING_DATA], + "field_str_min": [get_min_length_error_msg(2)], + "field_str_max": [get_max_length_error_msg(3)], + "field_str_min_and_max": [get_length_range_error_msg(2, 3)], + "field_str_equal": [get_length_equal_error_msg(3)], + "field_str_regex": [INVALID_STRING_PATTERN], + "field_str_regex_msg": [ + MarshmallowErrorContainer(SchemaValidationError.FORMAT, "This is the override error") + ], + "field_str_email": [INVALID_EMAIL], + "field_str_one_of": [get_one_of_error_msg(["a", "b"])], + "field_url": [INVALID_URL], + "field_int": [INVALID_INTEGER], + "field_int_required": [MISSING_DATA], + "field_int_strict": [INVALID_INTEGER], + "field_int_32bit": [INVALID_INTEGER_32BIT], + "field_int_min": [get_min_value_error_msg(10)], + "field_int_max": [get_max_value_error_msg(99)], + "field_int_min_max": [get_max_or_min_value_error_msg(40, 60)], + "field_int_min_max_with_32bit": [get_max_or_min_value_error_msg(1, 10)], + "field_bool": [INVALID_BOOLEAN], + "field_bool_required": [MISSING_DATA], + "field_decimal": [INVALID_DECIMAL], + "field_decimal_required": [MISSING_DATA], + "field_decimal_special": [INVALID_SPECIAL_DECIMAL], + "field_float": [INVALID_FLOAT], + "field_float_required": [MISSING_DATA], + "field_float_special": [INVALID_SPECIAL_FLOAT], + "field_uuid": [INVALID_UUID], + "field_uuid_required": [MISSING_DATA], + "field_date": [INVALID_DATE], + "field_date_required": [MISSING_DATA], + "field_date_format": [INVALID_DATE], + "field_datetime": [INVALID_DATETIME], + "field_datetime_required": [MISSING_DATA], + "field_datetime_format": [INVALID_DATETIME], + "field_time": [INVALID_TIME], + "field_time_required": [MISSING_DATA], + "field_time_format": [INVALID_TIME], + "field_list": [INVALID_LIST], + "field_list_required": [MISSING_DATA], + "field_list_indexed": {0: [INVALID_INTEGER], 2: [INVALID_INTEGER]}, + "field_nested": {"inner_str": [INVALID_STRING], "inner_required_str": [MISSING_DATA]}, + "field_nested_invalid": INVALID_SCHEMA, + "field_nested_required": [MISSING_DATA], + "field_list_nested": { + 0: {"inner_str": [INVALID_STRING]}, + 1: {"inner_required_str": [MISSING_DATA]}, + 2: INVALID_SCHEMA, + }, + "field_list_nested_invalid": [INVALID_LIST], + "field_list_nested_required": [MISSING_DATA], + "field_raw_required": [MISSING_DATA], + "field_enum": [get_enum_error_msg(EnumA)], + "field_enum_invalid_choice": [get_enum_error_msg(EnumA)], + "field_enum_invalid_type": [get_enum_error_msg(EnumA)], + "field_enum_required": [MISSING_DATA], + "field_file": [INVALID_FILE], + "field_file_required": [MISSING_DATA], + } diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/test_search_schema.py b/backend/grants_shared/tests/grants_shared/api/schemas/test_search_schema.py new file mode 100644 index 0000000..9825cd6 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/api/schemas/test_search_schema.py @@ -0,0 +1,216 @@ +from enum import StrEnum + +import pytest + +from grants_shared.api.schemas.extension import Schema, SchemaValidationError, fields +from grants_shared.api.schemas.search_schema import ( + BoolSearchSchemaBuilder, + DateSearchSchemaBuilder, + IntegerSearchSchemaBuilder, + StrSearchSchemaBuilder, + UuidSearchSchemaBuilder, +) +from tests.grants_shared.api.schemas.schema_validation_utils import validate_expected_errors + + +class MyEnum(StrEnum): + A = "a" + B = "b" + C = "c" + + +class ExampleSchema(Schema): + + my_str_field = fields.Nested( + StrSearchSchemaBuilder("MyStrFieldSchema") + .with_one_of(example="hello", minimum_length=3) + .build() + ) + + my_enum_field = fields.Nested( + StrSearchSchemaBuilder("MyEnumFieldSchema").with_one_of(allowed_values=MyEnum).build() + ) + + my_pattern_field = fields.Nested( + StrSearchSchemaBuilder("MyPatternFieldSchema").with_one_of(pattern=r"^\d{2}$").build() + ) + + positive_int_field = fields.Nested( + IntegerSearchSchemaBuilder("MyPositiveIntFieldSchema") + .with_integer_range(positive_only=True) + .build() + ) + all_int_field = fields.Nested( + IntegerSearchSchemaBuilder("MyAllIntFieldSchema") + .with_integer_range(min_example=-100, max_example=0, positive_only=False) + .build() + ) + + bool_field = fields.Nested( + BoolSearchSchemaBuilder("MyBoolFieldSchema").with_one_of(example=True).build() + ) + + date_range_field = fields.Nested( + DateSearchSchemaBuilder("MyDateRangeFieldSchema").with_date_range().build() + ) + + uuid_field = fields.Nested( + UuidSearchSchemaBuilder("MyUuidSearchFieldSchema").with_one_of(minimum_length=1).build() + ) + uuid_null_min_field = fields.Nested( + UuidSearchSchemaBuilder("MyUuidSearchFieldSchema").with_one_of(minimum_length=None).build() + ) + + +@pytest.mark.parametrize( + "data", + [ + # Empty is fine + {}, + # Various valid string cases + { + "my_str_field": {"one_of": ["hello"]}, + "my_enum_field": {"one_of": ["a", "b"]}, + "my_pattern_field": {"one_of": ["12"]}, + }, + { + "my_str_field": {"one_of": ["abc", "xyz"]}, + "my_enum_field": {"one_of": ["c", "a"]}, + "my_pattern_field": {"one_of": ["56", "78", "09"]}, + }, + # Various int cases + {"positive_int_field": {"min": 10, "max": 100}, "all_int_field": {"min": 0, "max": 100}}, + {"positive_int_field": {"min": 0, "max": 1}, "all_int_field": {"min": -5, "max": -1}}, + {"positive_int_field": {"min": 35}, "all_int_field": {"max": -13}}, + # Various bool cases + {"bool_field": {"one_of": [True]}}, + {"bool_field": {"one_of": [False]}}, + {"bool_field": {"one_of": [True, False]}}, + # Various date cases + {"date_range_field": {"start_date": "2025-01-01", "end_date": "2025-12-31"}}, + {"date_range_field": {"start_date": "2023-04-03"}}, + {"date_range_field": {"end_date": "2024-05-13"}}, + {"date_range_field": {"start_date_relative": -10, "end_date_relative": 20}}, + {"date_range_field": {"start_date_relative": 35, "end_date_relative": 144}}, + {"date_range_field": {"start_date_relative": 15}}, + {"date_range_field": {"end_date_relative": -20}}, + {"date_range_field": {"start_date": "2022-04-04", "end_date_relative": 100}}, + {"date_range_field": {"start_date_relative": 13, "end_date": "2026-12-31"}}, + # Various UUID cases + { + "uuid_field": {"one_of": ["6381d5ae-0334-4b46-9fc7-3717f7829acd"]}, + "uuid_null_min_field": {"one_of": []}, + }, + { + "uuid_field": { + "one_of": [ + "6381d5ae-0334-4b46-9fc7-3717f7829acd", + "9f506449-9968-487b-a835-39cbb7ed0ed4", + ] + }, + "uuid_null_min_field": {"one_of": ["a3cab250-f15c-464b-a5a0-7c8db58fe162"]}, + }, + ], +) +def test_valid_data_for_schema(data): + issues = ExampleSchema().validate(data) + assert len(issues) == 0 + + +@pytest.mark.parametrize( + "data,expected_errors", + [ + # Various string issues + ( + { + "my_str_field": {"one_of": ["a"]}, + "my_enum_field": {"one_of": ["x"]}, + "my_pattern_field": {"one_of": ["123"]}, + }, + { + "my_str_field.one_of.0": SchemaValidationError.MIN_LENGTH, + "my_enum_field.one_of.0": SchemaValidationError.INVALID_CHOICE, + "my_pattern_field.one_of.0": SchemaValidationError.FORMAT, + }, + ), + ( + {"my_str_field": {"one_of": [45]}, "my_enum_field": {"one_of": []}}, + { + "my_str_field.one_of.0": SchemaValidationError.INVALID, + "my_enum_field.one_of": SchemaValidationError.MIN_LENGTH, + }, + ), + # Various int issues + ( + {"positive_int_field": {}, "all_int_field": {}}, + { + "positive_int_field._schema": SchemaValidationError.REQUIRED, + "all_int_field._schema": SchemaValidationError.REQUIRED, + }, + ), + ( + {"positive_int_field": {"min": -5}, "all_int_field": {"max": "hello"}}, + { + "positive_int_field.min": SchemaValidationError.MIN_VALUE, + "all_int_field.max": SchemaValidationError.INVALID, + }, + ), + # Various bool issues + ( + {"bool_field": {"one_of": ["hello"]}}, + {"bool_field.one_of.0": SchemaValidationError.INVALID}, + ), + # Various date range issues + ( + {"date_range_field": {"start_date": "hello", "end_date": "123-45-67"}}, + { + "date_range_field.start_date": SchemaValidationError.INVALID, + "date_range_field.end_date": SchemaValidationError.INVALID, + }, + ), + ( + {"date_range_field": {"start_date_relative": "hello", "end_date_relative": {}}}, + { + "date_range_field.start_date_relative": SchemaValidationError.INVALID, + "date_range_field.end_date_relative": SchemaValidationError.INVALID, + }, + ), + ({"date_range_field": {}}, {"date_range_field._schema": SchemaValidationError.REQUIRED}), + ( + {"date_range_field": {"start_date": "2026-01-01", "start_date_relative": 13}}, + {"date_range_field._schema": SchemaValidationError.INVALID}, + ), + ( + {"date_range_field": {"end_date": "2023-07-07", "end_date_relative": 7}}, + {"date_range_field._schema": SchemaValidationError.INVALID}, + ), + # Various UUID issues + ( + {"uuid_field": {"one_of": []}, "uuid_null_min_field": {"one_of": ["hello"]}}, + { + "uuid_field.one_of": SchemaValidationError.MIN_LENGTH, + "uuid_null_min_field.one_of.0": SchemaValidationError.INVALID, + }, + ), + ( + { + "uuid_field": {"one_of": ["a3cab250-f15c-464b-a5a0-7c8db58fe162", 565]}, + "uuid_null_min_field": {"one_of": ["a-3-c-ab7c8db58fe162"]}, + }, + { + "uuid_field.one_of.1": SchemaValidationError.INVALID, + "uuid_null_min_field.one_of.0": SchemaValidationError.INVALID, + }, + ), + ], +) +def test_invalid_data_for_schema(data, expected_errors): + issues = ExampleSchema().validate(data) + validate_expected_errors(issues, expected_errors) + + +def test_string_schema_pattern_and_allowed_values(): + with pytest.raises(Exception, match="Cannot specify both a pattern and allowed_values"): + StrSearchSchemaBuilder("BadSchema123").with_one_of( + pattern=r"^\d{2}$", allowed_values=MyEnum + ) diff --git a/backend/grants_shared/tests/grants_shared/api/test_maintenance_mode.py b/backend/grants_shared/tests/grants_shared/api/test_maintenance_mode.py new file mode 100644 index 0000000..a4fd45f --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/api/test_maintenance_mode.py @@ -0,0 +1,136 @@ +import logging + +import pytest +from apiflask import APIFlask + +import grants_shared.logs +from grants_shared.api.maintenance_mode import ( + MaintenanceModeLogEvent, + get_maintenance_mode_config, + is_maintenance_mode_enabled, + register_maintenance_mode_handler, +) +from grants_shared.api.response import restructure_error_response +from grants_shared.api.schemas.response_schema import ErrorResponseSchema + +ALLOW_LISTED_PATH = "/health" + + +@pytest.fixture(autouse=True) +def clear_maintenance_config_cache(): + get_maintenance_mode_config.cache_clear() + yield + get_maintenance_mode_config.cache_clear() + + +@pytest.fixture +def enable_maintenance_mode(monkeypatch): + monkeypatch.setenv("ENABLE_MAINTENANCE_MODE", "true") + get_maintenance_mode_config.cache_clear() + + +@pytest.fixture +def maintenance_client(monkeypatch): + app = APIFlask(__name__, title="maintenance_test_app") + app.config["HTTP_ERROR_SCHEMA"] = ErrorResponseSchema + app.config["VALIDATION_ERROR_SCHEMA"] = ErrorResponseSchema + + @app.error_processor + def error_processor(error): + return restructure_error_response(error) + + register_maintenance_mode_handler(app, {ALLOW_LISTED_PATH}) + + @app.get("/example") + def example(): + return {"message": "ok"} + + @app.get(ALLOW_LISTED_PATH) + def health(): + return {"message": "healthy"} + + with grants_shared.logs.init(__package__): + yield app.test_client() + + +################# +# Config / helper +################# + + +def test_maintenance_mode_defaults_to_disabled(monkeypatch): + monkeypatch.delenv("ENABLE_MAINTENANCE_MODE", raising=False) + assert is_maintenance_mode_enabled() is False + + +@pytest.mark.parametrize("value", ["true", "True", "TRUE", "1"]) +def test_maintenance_mode_enabled_for_truthy_values(monkeypatch, value): + monkeypatch.setenv("ENABLE_MAINTENANCE_MODE", value) + assert is_maintenance_mode_enabled() is True + + +@pytest.mark.parametrize("value", ["false", "False", "FALSE", "0"]) +def test_maintenance_mode_disabled_for_falsy_values(monkeypatch, value): + monkeypatch.setenv("ENABLE_MAINTENANCE_MODE", value) + assert is_maintenance_mode_enabled() is False + + +def test_retry_after_seconds_defaults_to_3600(monkeypatch): + monkeypatch.delenv("MAINTENANCE_RETRY_AFTER_SECONDS", raising=False) + assert get_maintenance_mode_config().retry_after_seconds == 3600 + + +def test_retry_after_seconds_honors_override(monkeypatch): + monkeypatch.setenv("MAINTENANCE_RETRY_AFTER_SECONDS", "120") + assert get_maintenance_mode_config().retry_after_seconds == 120 + + +################# +# Handler +################# + + +def test_request_proceeds_normally_when_maintenance_mode_off(maintenance_client): + response = maintenance_client.get("/example") + assert response.status_code == 200 + assert response.get_json()["message"] == "ok" + + +def test_non_allow_listed_request_returns_503_when_maintenance_mode_on( + maintenance_client, enable_maintenance_mode +): + response = maintenance_client.get("/example") + + assert response.status_code == 503 + assert response.headers["Retry-After"] == str(get_maintenance_mode_config().retry_after_seconds) + + resp_json = response.get_json() + assert resp_json["message"] == "API is undergoing scheduled maintenance" + assert resp_json["status_code"] == 503 + assert resp_json["errors"] == [] + + +def test_allow_listed_path_is_served_when_maintenance_mode_on( + maintenance_client, enable_maintenance_mode +): + response = maintenance_client.get(ALLOW_LISTED_PATH) + + assert response.status_code == 200 + assert response.get_json()["message"] == "healthy" + + +def test_maintenance_rejection_emits_distinct_log_event( + maintenance_client, enable_maintenance_mode, caplog +): + caplog.set_level(logging.INFO) + + maintenance_client.get("/example") + + rejection_records = [ + record + for record in caplog.records + if getattr(record, "maintenance_mode_event", None) + == MaintenanceModeLogEvent.REQUEST_REJECTED + ] + assert len(rejection_records) == 1 + assert rejection_records[0].message == "Request rejected due to maintenance mode" diff --git a/backend/grants_shared/tests/grants_shared/api/test_route_error_format.py b/backend/grants_shared/tests/grants_shared/api/test_route_error_format.py new file mode 100644 index 0000000..f0af40b --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/api/test_route_error_format.py @@ -0,0 +1,299 @@ +""" +There are several ways errors can be thrown by the API + +These tests aim to verify that the format and structure of the error +responses is consistent and functioning as intended. +""" + +import dataclasses + +import pytest +from apiflask import APIBlueprint, APIFlask, APIKeyHeaderAuth +from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized +from werkzeug.http import HTTP_STATUS_CODES + +import grants_shared.logs +from grants_shared.api.response import ( + ApiResponse, + ValidationErrorDetail, + restructure_error_response, +) +from grants_shared.api.route_utils import raise_flask_error +from grants_shared.api.schemas.extension import Schema, fields +from grants_shared.api.schemas.response_schema import ( + AbstractResponseSchema, + ErrorResponseSchema, + WarningMixinSchema, +) +from grants_shared.util.dict_util import flatten_dict +from tests.grants_shared.api.schemas.schema_validation_utils import ( + FieldTestSchema, + get_expected_validation_errors, + get_invalid_field_test_schema_req, + get_valid_field_test_schema_req, +) + +PATH = "/test/" +VALID_UUID = "1234a5b6-7c8d-90ef-1ab2-c3d45678e9f0" +FULL_PATH = PATH + VALID_UUID +TEST_TOKEN = "test-token" + +# Simple test-only auth that doesn't require a database connection. +# This file tests error response formatting, not authentication behavior. +test_auth = APIKeyHeaderAuth("ApiKey", param_name="X-API-Key", security_scheme_name="TestAuth") + + +@test_auth.verify_token +def verify_test_token(token: str): + if token == TEST_TOKEN: + return {"username": "test"} + raise_flask_error( + 401, "The server could not verify that you are authorized to access the URL requested" + ) + + +def header(): + return {"X-API-Key": TEST_TOKEN} + + +class OutputData(Schema): + output_val = fields.String() + + +class OutputSchema(AbstractResponseSchema, WarningMixinSchema): + data = fields.Nested(OutputData()) + + +test_blueprint = APIBlueprint("test", __name__, tag="test") + + +class OverridenClass: + """ + In order to arbitrarily change the implementation of + the test endpoint, create a simple function that tests + below can override by doing:: + + def override(self): + # if this method returns, it returns + # the response as a dictionary + a list + # of validation issues to attach to the response + return {"output_val": "hello"}, [] + + monkeypatch.setattr(OverridenClass, "override_method", override) + """ + + def override_method(self): + return {"output_val": "hello"}, [] + + +@test_blueprint.patch("/test/") +@test_blueprint.input(FieldTestSchema, arg_name="req") +@test_blueprint.output(OutputSchema) +@test_blueprint.auth_required(test_auth) +def api_method(test_id, req): + resp, warnings = OverridenClass().override_method() + return ApiResponse("Test method run successfully", data=resp, warnings=warnings) + + +@pytest.fixture +def simple_app(monkeypatch): + # Create a minimal app that restructures error responses + app = APIFlask(__name__, title="test_app") + + app.config["HTTP_ERROR_SCHEMA"] = ErrorResponseSchema + app.config["VALIDATION_ERROR_SCHEMA"] = ErrorResponseSchema + + @app.error_processor + def error_processor(error): + return restructure_error_response(error) + + # To avoid re-initializing logging everytime we + # setup the app, we disabled it above and do it here + # in case you want it while running your tests + with grants_shared.logs.init(__package__): + yield app + + +@pytest.fixture +def simple_client(simple_app): + simple_app.register_blueprint(test_blueprint) + return simple_app.test_client() + + +@pytest.mark.parametrize( + "exception", [Exception, AttributeError, IndexError, NotImplementedError, ValueError] +) +def test_exception(simple_client, monkeypatch, exception): + def override(self): + raise exception("Exception message text") + + monkeypatch.setattr(OverridenClass, "override_method", override) + + resp = simple_client.patch(FULL_PATH, json=get_valid_field_test_schema_req(), headers=header()) + + assert resp.status_code == 500 + resp_json = resp.get_json() + assert resp_json["errors"] == [] + assert resp_json["message"] == "Internal Server Error" + + +@pytest.mark.parametrize("exception", [Unauthorized, NotFound, Forbidden, BadRequest]) +def test_werkzeug_exceptions(simple_client, monkeypatch, exception): + def override(self): + raise exception("Exception message text") + + monkeypatch.setattr(OverridenClass, "override_method", override) + + resp = simple_client.patch(FULL_PATH, json=get_valid_field_test_schema_req(), headers=header()) + + # Werkzeug errors use the proper status code, but + # any message is replaced with a generic one they have defined + assert resp.status_code == exception.code + resp_json = resp.get_json() + assert resp_json["data"] == {} + assert resp_json["errors"] == [] + assert resp_json["message"] == HTTP_STATUS_CODES[exception.code] + + +@pytest.mark.parametrize( + "error_code,message,detail,validation_issues", + [ + (422, "message", {"field": "value"}, []), + ( + 422, + "message but different", + None, + [ + ValidationErrorDetail( + type="example", message="example message", field="example_field" + ), + ValidationErrorDetail( + type="example2", message="example message2", field="example_field2" + ), + ], + ), + (401, "not allowed", {"field": "value"}, []), + (403, "bad request message", None, []), + ], +) +def test_flask_error(simple_client, monkeypatch, error_code, message, detail, validation_issues): + def override(self): + raise_flask_error(error_code, message, detail=detail, validation_issues=validation_issues) + + monkeypatch.setattr(OverridenClass, "override_method", override) + + resp = simple_client.patch(FULL_PATH, json=get_valid_field_test_schema_req(), headers=header()) + + assert resp.status_code == error_code + resp_json = resp.get_json() + assert resp_json["message"] == message + + if detail is None: + assert resp_json["data"] == {} + else: + assert resp_json["data"] == detail + + if validation_issues: + errors = resp_json["errors"] + assert len(validation_issues) == len(errors) + + for validation_issue in validation_issues: + assert dataclasses.asdict(validation_issue) in errors + else: + assert resp_json["errors"] == [] + + +def test_invalid_path_param(simple_client, monkeypatch): + resp = simple_client.patch( + PATH + "not-a-uuid", json=get_valid_field_test_schema_req(), headers=header() + ) + + # This raises a Werkzeug NotFound so has those values + assert resp.status_code == 404 + resp_json = resp.get_json() + assert resp_json["data"] == {} + assert resp_json["errors"] == [] + assert resp_json["message"] == "Not Found" + + +def test_auth_error(simple_client, monkeypatch): + resp = simple_client.patch( + FULL_PATH, json=get_valid_field_test_schema_req(), headers={"X-API-Key": "not_valid_key"} + ) + + assert resp.status_code == 401 + resp_json = resp.get_json() + assert resp_json["data"] == {} + assert resp_json["errors"] == [] + assert ( + resp_json["message"] + == "The server could not verify that you are authorized to access the URL requested" + ) + + +@pytest.mark.parametrize( + "issues", + [ + [], + [ + ValidationErrorDetail( + type="required", message="Field is required", field="sub_obj.field_a" + ), + ValidationErrorDetail( + type="format", message="Invalid format for type string", field="field_b" + ), + ], + [ValidationErrorDetail(type="bad", message="field is optional technically")], + ], +) +def test_added_validation_issues(simple_client, monkeypatch, issues): + def override(self): + return {"output_val": "hello with validation issues"}, issues + + monkeypatch.setattr(OverridenClass, "override_method", override) + + resp = simple_client.patch(FULL_PATH, json=get_valid_field_test_schema_req(), headers=header()) + + assert resp.status_code == 200 + resp_json = resp.get_json() + assert resp_json["data"] == {"output_val": "hello with validation issues"} + assert resp_json["message"] == "Test method run successfully" + + warnings = resp_json["warnings"] + + assert len(issues) == len(warnings) + for issue in issues: + assert dataclasses.asdict(issue) in warnings + + +def test_marshmallow_validation(simple_client, monkeypatch): + """ + Validate that Marshmallow errors get transformed properly + and attached in the expected format in an error response + """ + + req = get_invalid_field_test_schema_req() + resp = simple_client.patch(FULL_PATH, json=req, headers=header()) + + assert resp.status_code == 422 + resp_json = resp.get_json() + assert resp_json["data"] == {} + assert resp_json["message"] == "Validation error" + + resp_errors = resp_json["errors"] + + expected_errors = [] + for field, errors in flatten_dict(get_expected_validation_errors()).items(): + for error in errors: + expected_errors.append( + { + "type": error.key, + "message": error.message, + "field": field.removesuffix("._schema"), + "value": None, + } + ) + + assert len(expected_errors) == len(resp_errors) + for expected_error in expected_errors: + assert expected_error in resp_errors diff --git a/backend/grants_shared/tests/grants_shared/auth/__init__.py b/backend/grants_shared/tests/grants_shared/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/auth/test_api_jwt_auth.py b/backend/grants_shared/tests/grants_shared/auth/test_api_jwt_auth.py new file mode 100644 index 0000000..9ff5bb6 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/auth/test_api_jwt_auth.py @@ -0,0 +1,340 @@ +import uuid +from calendar import timegm +from datetime import datetime + +import jwt +import pytest +from apiflask import APIBlueprint, APIFlask, APIKeyHeaderAuth +from freezegun import freeze_time + +import grants_shared.logs +from grants_shared.api.response import restructure_error_response +from grants_shared.api.route_utils import raise_flask_error +from grants_shared.api.schemas.response_schema import ErrorResponseSchema +from grants_shared.auth import api_jwt_auth +from grants_shared.auth.api_jwt_auth import ApiJwtConfig, JwtAuth, refresh_token_expiration +from grants_shared.auth.auth_errors import JwtValidationError +from tests.grants_shared.db.models.factories import SharedLinkExternalUserFactory, SharedUserFactory +from tests.grants_shared.db_test_models.db_test_models import SharedUserTokenSession +from tests.grants_shared.test_utils.auth_handler import AuthHandler + + +@pytest.fixture +def jwt_config(private_rsa_key, public_rsa_key): + return ApiJwtConfig( + API_JWT_PRIVATE_KEY=private_rsa_key, + API_JWT_PUBLIC_KEY=public_rsa_key, + ) + + +@pytest.fixture +def simple_app(monkeypatch): + """Create a minimal Flask app for testing JWT auth in HTTP context""" + app = APIFlask(__name__, title="test_jwt_app") + + app.config["HTTP_ERROR_SCHEMA"] = ErrorResponseSchema + app.config["VALIDATION_ERROR_SCHEMA"] = ErrorResponseSchema + + @app.error_processor + def error_processor(error): + return restructure_error_response(error) + + with grants_shared.logs.init(__package__): + yield app + + +@pytest.fixture +def simple_client(simple_app, db_session, jwt_config, monkeypatch): + """Register test blueprint and return test client""" + # Create auth object following the production pattern + test_jwt_auth = APIKeyHeaderAuth( + "ApiKey", param_name="X-SGG-Token", security_scheme_name="ApiJwtAuth" + ) + + @test_jwt_auth.verify_token + def decode_token(token: str): + """Verify token and return token session (following production pattern)""" + try: + token_session = JwtAuth(AuthHandler(db_session), jwt_config).parse_jwt_for_user(token) + return token_session + except JwtValidationError as e: + raise_flask_error(401, e.message) + + # Create a test blueprint + test_blueprint = APIBlueprint("test_jwt", __name__, tag="test") + + @test_blueprint.get("/test_jwt_endpoint") + @test_blueprint.auth_required(test_jwt_auth) + def test_jwt_endpoint(): + token_session = test_jwt_auth.current_user + return { + "message": "Success", + "data": { + "user_id": str(token_session.shared_user_id), + "token_id": str(token_session.token_id), + }, + } + + simple_app.register_blueprint(test_blueprint) + return simple_app.test_client() + + +@freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_create_jwt_for_user(enable_factory_create, db_session, jwt_config): + """Unit test for JWT creation - validates token structure and database session""" + user = SharedUserFactory.create() + linked_external_user = SharedLinkExternalUserFactory.create(shared_user=user) + token, token_session = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user( + user, None + ) + decoded_token = jwt.decode( + token, algorithms=[jwt_config.algorithm], options={"verify_signature": False} + ) + + # Verify the issued at timestamp is at the expected (now) timestamp + # note we have to convert it to a unix timestamp + assert decoded_token["iat"] == timegm( + datetime.fromisoformat("2024-11-14 12:00:00+00:00").utctimetuple() + ) + assert decoded_token["user_id"] == str(user.shared_user_id) + assert decoded_token["email"] is None + assert decoded_token["iss"] == jwt_config.issuer + assert decoded_token["aud"] == jwt_config.audience + + token_with_email, token_session = JwtAuth( + AuthHandler(db_session), jwt_config + ).create_jwt_for_user(user, linked_external_user.email) + decoded_token_with_email = jwt.decode( + token_with_email, algorithms=[jwt_config.algorithm], options={"verify_signature": False} + ) + assert decoded_token_with_email["email"] == linked_external_user.email + + # Verify that the sub_id returned can be used to fetch a UserTokenSession object + token_session = ( + db_session.query(SharedUserTokenSession) + .filter(SharedUserTokenSession.token_id == decoded_token["sub"]) + .one_or_none() + ) + + assert token_session.shared_user_id == user.shared_user_id + assert token_session.is_valid is True + # Verify expires_at is set to 30 minutes after now by default + assert token_session.expires_at == datetime.fromisoformat("2024-11-14 12:30:00+00:00") + + # Basic testing that the JWT we create for a user can in turn be fetched and processed later + user_session = JwtAuth(AuthHandler(db_session), jwt_config).parse_jwt_for_user(token) + assert user_session.shared_user_id == user.shared_user_id + + +def test_parse_jwt_for_user_succeeds(simple_client, enable_factory_create, db_session, jwt_config): + """Test JWT auth succeeds with valid token in HTTP context""" + user = SharedUserFactory.create() + token, _ = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user(user, None) + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 200 + resp_json = resp.get_json() + assert resp_json["message"] == "Success" + assert resp_json["data"]["user_id"] == str(user.shared_user_id) + + +@freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_parse_jwt_for_user_fails_when_token_not_yet_valid( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails when token has future iat timestamp""" + user = SharedUserFactory.create() + + future_time = datetime.fromisoformat("2024-11-14 13:00:00+00:00") + payload = { + "sub": str(user.shared_user_id), + "iat": future_time, + "aud": jwt_config.audience, + "iss": jwt_config.issuer, + "user_id": str(user.shared_user_id), + } + token = jwt.encode(payload, jwt_config.private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token not yet valid" + + +def test_parse_jwt_for_user_fails_when_token_has_unknown_issuer( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails with unknown issuer in HTTP context""" + user = SharedUserFactory.create() + + current_time = datetime.fromisoformat("2024-11-14 12:00:00+00:00") + payload = { + "sub": str(user.shared_user_id), + "iat": current_time, + "aud": jwt_config.audience, + "iss": "unknown-issuer", + "user_id": str(user.shared_user_id), + } + token = jwt.encode(payload, jwt_config.private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Unknown Issuer" + + +def test_parse_jwt_for_user_fails_when_token_has_unknown_audience( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails with unknown audience in HTTP context""" + user = SharedUserFactory.create() + + current_time = datetime.fromisoformat("2024-11-14 12:00:00+00:00") + payload = { + "sub": str(user.shared_user_id), + "iat": current_time, + "aud": "unknown-audience", + "iss": jwt_config.issuer, + "user_id": str(user.shared_user_id), + } + token = jwt.encode(payload, jwt_config.private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Unknown Audience" + + +def test_parse_jwt_for_user_fails_when_unable_to_process_token( + simple_client, enable_factory_create, db_session, jwt_config, other_rsa_key_pair +): + """Test JWT auth fails when token is signed with different key""" + user = SharedUserFactory.create() + + current_time = datetime.fromisoformat("2024-11-14 12:00:00+00:00") + payload = { + "sub": str(user.shared_user_id), + "iat": current_time, + "aud": jwt_config.audience, + "iss": jwt_config.issuer, + "user_id": str(user.shared_user_id), + } + other_private_key = other_rsa_key_pair[0] + token = jwt.encode(payload, other_private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Unable to process token" + + +def test_parse_jwt_for_user_fails_when_token_missing_sub_field( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails when token is missing sub field""" + user = SharedUserFactory.create() + + current_time = datetime.fromisoformat("2024-11-14 12:00:00+00:00") + payload = { + "iat": current_time, + "aud": jwt_config.audience, + "iss": jwt_config.issuer, + "user_id": str(user.shared_user_id), + } + token = jwt.encode(payload, jwt_config.private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token missing sub field" + + +def test_parse_jwt_for_user_fails_when_token_session_is_none( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails when token session doesn't exist in DB""" + current_time = datetime.fromisoformat("2024-11-14 12:00:00+00:00") + non_existent_token_id = str(uuid.uuid4()) + payload = { + "sub": non_existent_token_id, + "iat": current_time, + "aud": jwt_config.audience, + "iss": jwt_config.issuer, + "user_id": str(uuid.uuid4()), + } + token = jwt.encode(payload, jwt_config.private_key, algorithm="RS256") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token session does not exist" + + +def test_parse_jwt_for_user_fails_when_token_is_expired( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails with expired token in HTTP context""" + user = SharedUserFactory.create() + token, token_session = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user( + user, None + ) + token_session.expires_at = datetime.fromisoformat("1980-01-01 12:00:00+00:00") + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token expired" + + +def test_parse_jwt_for_user_fails_when_token_is_no_longer_valid( + simple_client, enable_factory_create, db_session, jwt_config +): + """Test JWT auth fails with invalidated token in HTTP context""" + user = SharedUserFactory.create() + token, token_session = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user( + user, None + ) + token_session.is_valid = False + + resp = simple_client.get("/test_jwt_endpoint", headers={"X-SGG-Token": token}) + + assert resp.status_code == 401 + assert resp.get_json()["message"] == "Token is no longer valid" + + +@freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_refresh_token_expiration_succeeds(enable_factory_create, db_session, jwt_config): + user = SharedUserFactory.create() + token, token_session = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user( + user, None + ) + + original_expiration = token_session.expires_at + assert original_expiration == datetime.fromisoformat("2024-11-14 12:30:00+00:00") + + with freeze_time("2024-11-14 12:15:00", tz_offset=0): + refreshed_session = refresh_token_expiration(token_session, jwt_config) + + assert refreshed_session.expires_at == datetime.fromisoformat("2024-11-14 12:45:00+00:00") + assert refreshed_session.expires_at != original_expiration + + +@freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_refresh_token_expiration_handles_config_of_none( + enable_factory_create, db_session, jwt_config, monkeypatch +): + monkeypatch.setattr(api_jwt_auth, "_config", jwt_config) + + user = SharedUserFactory.create() + token, token_session = JwtAuth(AuthHandler(db_session), jwt_config).create_jwt_for_user( + user, None + ) + + original_expiration = token_session.expires_at + + with freeze_time("2024-11-14 12:15:00", tz_offset=0): + refreshed_session = refresh_token_expiration(token_session, config=None) + + assert refreshed_session.expires_at == datetime.fromisoformat("2024-11-14 12:45:00+00:00") + assert refreshed_session.expires_at != original_expiration diff --git a/backend/grants_shared/tests/grants_shared/auth/test_api_key_handler.py b/backend/grants_shared/tests/grants_shared/auth/test_api_key_handler.py new file mode 100644 index 0000000..12028e0 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/auth/test_api_key_handler.py @@ -0,0 +1,554 @@ +import string +import uuid +from datetime import timedelta +from unittest.mock import patch + +import apiflask +import pytest +from sqlalchemy import select + +from grants_shared.adapters import db +from grants_shared.auth.api_key_handler import MAX_KEY_GENERATION_RETRIES, KeyGenerationError +from grants_shared.util import datetime_util +from tests.grants_shared.db.models.factories import SharedUserApiKeyFactory, SharedUserFactory +from tests.grants_shared.db_test_models.db_test_models import SharedUserApiKey +from tests.grants_shared.test_utils.auth_handler import SharedApiKeyHandler + + +def test_create_api_key_success(enable_factory_create, db_session: db.Session, caplog): + """Test that create_api_key successfully creates a new API key with auto-generated key_id.""" + user = SharedUserFactory.create() + + with caplog.at_level("INFO"): + api_key = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Test API Key", + ) + + # Verify that the API key was created successfully (AWS integration is mocked automatically) + + # Verify the API key was created correctly + assert api_key.shared_api_key_id is not None + assert api_key.shared_user_id == user.shared_user_id + assert api_key.key_name == "Test API Key" + assert api_key.key_id is not None + assert len(api_key.key_id) == 25 # Auto-generated key_id should be 25 characters + assert api_key.is_active is True + assert api_key.last_used is None + + # Verify the key_id contains only alphanumeric characters + allowed_chars = string.ascii_letters + string.digits + assert all(c in allowed_chars for c in api_key.key_id) + + # Verify it was persisted to the database + db_session.commit() + db_session.refresh(api_key) + assert api_key.created_at is not None + assert api_key.updated_at is not None + + assert any("Created new API key" in record.message for record in caplog.records) + + log_record = next( + record for record in caplog.records if "Created new API key" in record.message + ) + assert str(getattr(log_record, "auth.shared_api_key_id")) == str(api_key.shared_api_key_id) + assert str(getattr(log_record, "auth.shared_user_id")) == str(api_key.shared_user_id) + + +def test_create_api_key_generates_unique_key_ids(enable_factory_create, db_session: db.Session): + """Test that create_api_key generates unique key_ids for each API key.""" + user = SharedUserFactory.create() + + api_keys = [] + for i in range(3): + api_key = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name=f"Key {i}", + ) + api_keys.append(api_key) + + key_ids = [key.key_id for key in api_keys] + assert len(set(key_ids)) == len(key_ids), "All key_ids should be unique" + + assert all(len(key_id) == 25 for key_id in key_ids) + + +def test_create_api_key_collision_detection(enable_factory_create, db_session: db.Session): + """Test that create_api_key handles key_id collisions by retrying.""" + user = SharedUserFactory.create() + + existing_key_id = "COLLISION_TEST_KEY_12345" + SharedUserApiKeyFactory.create( + shared_user=user, key_name="Existing Key", key_id=existing_key_id + ) + + with patch("grants_shared.auth.api_key_handler.generate_api_key_id") as mock_generate: + mock_generate.side_effect = [ + existing_key_id, + "UNIQUE_TEST_KEY_123456789", + ] + + api_key = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="New Key", + ) + + assert api_key.key_id == "UNIQUE_TEST_KEY_123456789" + assert mock_generate.call_count == 2 + + +def test_create_api_key_max_retries_exceeded(enable_factory_create, db_session: db.Session, caplog): + """Test that create_api_key raises KeyGenerationError when max retries exceeded.""" + user = SharedUserFactory.create() + + existing_key_id = "COLLISION_KEY_12345678901234" + SharedUserApiKeyFactory.create( + shared_user=user, key_name="Existing Key", key_id=existing_key_id + ) + + with patch("grants_shared.auth.api_key_handler.generate_api_key_id") as mock_generate: + mock_generate.return_value = existing_key_id # Always return the same colliding key + + with ( + caplog.at_level("ERROR"), + pytest.raises( + KeyGenerationError, + match="Unable to generate unique API key after 5 attempts", + ), + ): + SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Failed Key", + ) + + assert mock_generate.call_count == MAX_KEY_GENERATION_RETRIES + + assert any( + "Failed to generate unique key_id after maximum retries" in record.message + for record in caplog.records + ) + + error_log = next( + record + for record in caplog.records + if "Failed to generate unique key_id after maximum retries" in record.message + ) + assert hasattr(error_log, "max_retries") + assert error_log.max_retries == MAX_KEY_GENERATION_RETRIES + + +def test_create_api_key_aws_gateway_error_handling( + enable_factory_create, db_session: db.Session, caplog +): + """Test that API key creation works with the built-in AWS mock system.""" + # Note: With IS_LOCAL_AWS=1, the import_api_key function automatically uses mocks + # and should not raise exceptions under normal circumstances + + user = SharedUserFactory.create() + + # This should succeed using the built-in mock system + with caplog.at_level("INFO"): + api_key = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Test API Key", + ) + + # Verify the API key was created successfully + assert api_key.shared_api_key_id is not None + assert api_key.shared_user_id == user.shared_user_id + assert api_key.key_name == "Test API Key" + assert api_key.is_active is True + + # Verify success was logged + log_messages = [record.message for record in caplog.records] + assert any( + "Created new API key" in message for message in log_messages + ), f"Expected log message not found. Actual messages: {log_messages}" + + +def test_create_api_key_database_rollback_on_gateway_failure( + enable_factory_create, db_session: db.Session +): + """Test that database operations work correctly with the built-in AWS mock system.""" + # Note: With IS_LOCAL_AWS=1, the import_api_key function uses mocks and should not fail + # This test now verifies normal database operations + user = SharedUserFactory.create() + + # Count existing API keys before creation + initial_count = db_session.execute( + select(SharedUserApiKey).where(SharedUserApiKey.shared_user_id == user.shared_user_id) + ).all() + initial_count = len(initial_count) + + # This should succeed with the built-in mock system + api_key = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Test API Key", + ) + + # Verify that a new API key was persisted to the database + final_count = db_session.execute( + select(SharedUserApiKey).where(SharedUserApiKey.shared_user_id == user.shared_user_id) + ).all() + final_count = len(final_count) + + assert final_count == initial_count + 1, "One new API key should be persisted" + + # Verify the API key exists with the expected name + api_key_with_name = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_user_id == user.shared_user_id, + SharedUserApiKey.key_name == "Test API Key", + ) + ).scalar_one_or_none() + + assert api_key_with_name is not None, "API key with the test name should exist" + assert api_key_with_name.shared_api_key_id == api_key.shared_api_key_id + + +def test_create_api_key_multiple_keys_same_user(enable_factory_create, db_session: db.Session): + """Test that the same user can have multiple API keys with different names.""" + user = SharedUserFactory.create() + + api_key1 = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Production Key", + ) + + api_key2 = SharedApiKeyHandler(db_session).create_api_key( + user_id=user.shared_user_id, + key_name="Development Key", + ) + + assert api_key1.shared_user_id == api_key2.shared_user_id + assert api_key1.key_name != api_key2.key_name + assert api_key1.shared_api_key_id != api_key2.shared_api_key_id + assert api_key1.key_id != api_key2.key_id + + +def test_delete_api_key_success(enable_factory_create, db_session: db.Session, caplog): + """Test that delete_api_key successfully deletes an API key.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user, is_active=True) + + with caplog.at_level("INFO"): + SharedApiKeyHandler(db_session).delete_api_key( + user.shared_user_id, api_key.shared_api_key_id + ) + + db_api_key = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_api_key_id == api_key.shared_api_key_id + ) + ).scalar_one_or_none() + assert db_api_key is None + + assert any("Deleted API key" in record.message for record in caplog.records) + + log_record = next(record for record in caplog.records if "Deleted API key" in record.message) + assert getattr(log_record, "auth.shared_api_key_id") == api_key.shared_api_key_id + assert getattr(log_record, "auth.shared_user_id") == api_key.shared_user_id + + +def test_delete_api_key_not_found_wrong_user(enable_factory_create, db_session: db.Session): + """Test that delete_api_key raises 404 when API key doesn't belong to the user.""" + user1 = SharedUserFactory.create() + user2 = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user1, is_active=True) + + with pytest.raises(apiflask.exceptions.HTTPError) as exc_info: + SharedApiKeyHandler(db_session).delete_api_key( + user2.shared_user_id, api_key.shared_api_key_id + ) + + assert exc_info.value.status_code == 404 + assert "API key not found" in exc_info.value.message + + db_api_key = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_api_key_id == api_key.shared_api_key_id + ) + ).scalar_one_or_none() + assert db_api_key is not None + + +def test_delete_api_key_not_found_nonexistent(enable_factory_create, db_session: db.Session): + """Test that delete_api_key raises 404 when API key doesn't exist.""" + user = SharedUserFactory.create() + + with pytest.raises(apiflask.exceptions.HTTPError) as exc_info: + SharedApiKeyHandler(db_session).delete_api_key(user.shared_user_id, uuid.uuid4()) + + assert exc_info.value.status_code == 404 + assert "API key not found" in exc_info.value.message + + +def test_delete_api_key_already_inactive(enable_factory_create, db_session: db.Session): + """Test that delete_api_key can delete an already inactive API key.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user, is_active=False) + + SharedApiKeyHandler(db_session).delete_api_key(user.shared_user_id, api_key.shared_api_key_id) + + db_api_key = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_api_key_id == api_key.shared_api_key_id + ) + ).scalar_one_or_none() + assert db_api_key is None + + +def test_delete_api_key_multiple_keys_same_user(enable_factory_create, db_session: db.Session): + """Test that deleting one API key doesn't affect other API keys for the same user.""" + user = SharedUserFactory.create() + api_key1 = SharedUserApiKeyFactory.create(shared_user=user, is_active=True, key_name="Key 1") + api_key2 = SharedUserApiKeyFactory.create(shared_user=user, is_active=True, key_name="Key 2") + + SharedApiKeyHandler(db_session).delete_api_key(user.shared_user_id, api_key1.shared_api_key_id) + + db_api_key1 = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_api_key_id == api_key1.shared_api_key_id + ) + ).scalar_one_or_none() + db_api_key2 = db_session.execute( + select(SharedUserApiKey).where( + SharedUserApiKey.shared_api_key_id == api_key2.shared_api_key_id + ) + ).scalar_one_or_none() + assert db_api_key1 is None + assert db_api_key2 is not None + + +def test_get_user_api_keys_empty_result(enable_factory_create, db_session: db.Session): + """Test get_user_api_keys returns empty list when user has no API keys.""" + user = SharedUserFactory.create() + + api_keys = SharedApiKeyHandler(db_session).get_user_api_keys(user.shared_user_id) + + assert api_keys == [] + + +def test_get_user_api_keys_single_key(enable_factory_create, db_session: db.Session): + """Test get_user_api_keys returns single API key for user.""" + api_key = SharedUserApiKeyFactory.create(key_name="Test Key") + + api_keys = SharedApiKeyHandler(db_session).get_user_api_keys(api_key.shared_user_id) + + assert len(api_keys) == 1 + assert api_keys[0].shared_api_key_id == api_key.shared_api_key_id + assert api_keys[0].key_name == "Test Key" + assert api_keys[0].shared_user_id == api_key.shared_user_id + + +def test_get_user_api_keys_multiple_keys(enable_factory_create, db_session: db.Session): + """Test get_user_api_keys returns all API keys for user.""" + user = SharedUserFactory.create() + + api_key1 = SharedUserApiKeyFactory.create( + shared_user=user, + key_name="First Key", + created_at=datetime_util.utcnow() - timedelta(hours=2), + ) + api_key2 = SharedUserApiKeyFactory.create( + shared_user=user, + key_name="Second Key", + created_at=datetime_util.utcnow() - timedelta(hours=1), + ) + api_key3 = SharedUserApiKeyFactory.create( + shared_user=user, key_name="Third Key", created_at=datetime_util.utcnow() + ) + + api_keys = SharedApiKeyHandler(db_session).get_user_api_keys(user.shared_user_id) + + assert len(api_keys) == 3 + + assert api_keys[0].shared_api_key_id == api_key3.shared_api_key_id + assert api_keys[1].shared_api_key_id == api_key2.shared_api_key_id + assert api_keys[2].shared_api_key_id == api_key1.shared_api_key_id + + +def test_get_user_api_keys_only_users_keys(enable_factory_create, db_session: db.Session): + """Test get_user_api_keys only returns keys for the specified user.""" + + user1_key = SharedUserApiKeyFactory.create(key_name="User 1 Key") + user2_key = SharedUserApiKeyFactory.create(key_name="User 2 Key") + + user1_keys = SharedApiKeyHandler(db_session).get_user_api_keys(user1_key.shared_user_id) + + assert len(user1_keys) == 1 + assert user1_keys[0].shared_api_key_id == user1_key.shared_api_key_id + assert user1_keys[0].key_name == "User 1 Key" + + user2_keys = SharedApiKeyHandler(db_session).get_user_api_keys(user2_key.shared_user_id) + + assert len(user2_keys) == 1 + assert user2_keys[0].shared_api_key_id == user2_key.shared_api_key_id + assert user2_keys[0].key_name == "User 2 Key" + + +def test_get_user_api_key_success(enable_factory_create, db_session: db.Session): + """Test get_user_api_key returns the correct API key for a user.""" + + api_key = SharedUserApiKeyFactory.create(key_name="Test Key") + + result = SharedApiKeyHandler(db_session).get_user_api_key( + api_key.shared_user_id, api_key.shared_api_key_id + ) + + assert result.shared_api_key_id == api_key.shared_api_key_id + assert result.key_name == "Test Key" + assert result.shared_user_id == api_key.shared_user_id + + +def test_get_user_api_key_not_found_wrong_user(enable_factory_create, db_session: db.Session): + """Test get_user_api_key raises 404 when API key belongs to different user.""" + user1 = SharedUserFactory.create() + user2 = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user1, key_name="User 1 Key") + + with pytest.raises(apiflask.exceptions.HTTPError) as exc_info: + SharedApiKeyHandler(db_session).get_user_api_key( + user2.shared_user_id, api_key.shared_api_key_id + ) + + assert exc_info.value.status_code == 404 + assert "API key not found" in exc_info.value.message + + +def test_get_user_api_key_not_found_nonexistent(enable_factory_create, db_session: db.Session): + """Test get_user_api_key raises 404 when API key doesn't exist.""" + user = SharedUserFactory.create() + + with pytest.raises(apiflask.exceptions.HTTPError) as exc_info: + SharedApiKeyHandler(db_session).get_user_api_key(user.shared_user_id, uuid.uuid4()) + + assert exc_info.value.status_code == 404 + assert "API key not found" in exc_info.value.message + + +def test_rename_api_key_success(enable_factory_create, db_session: db.Session, caplog): + """Test that rename_api_key successfully renames an existing API key.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user, key_name="Original Key Name") + + with caplog.at_level("INFO"): + renamed_api_key = SharedApiKeyHandler(db_session).rename_api_key( + user_id=user.shared_user_id, + api_key_id=api_key.shared_api_key_id, + key_name="New Key Name", + ) + + assert renamed_api_key.shared_api_key_id == api_key.shared_api_key_id + assert renamed_api_key.shared_user_id == user.shared_user_id + assert renamed_api_key.key_name == "New Key Name" + assert renamed_api_key.key_id == api_key.key_id # key_id should remain unchanged + assert renamed_api_key.is_active == api_key.is_active + assert renamed_api_key.last_used == api_key.last_used + + db_session.commit() + db_session.refresh(renamed_api_key) + assert renamed_api_key.key_name == "New Key Name" + + assert any("Renamed API key" in record.message for record in caplog.records) + + log_record = next(record for record in caplog.records if "Renamed API key" in record.message) + assert str(getattr(log_record, "auth.shared_api_key_id")) == str(api_key.shared_api_key_id) + assert str(getattr(log_record, "auth.shared_user_id")) == str(api_key.shared_user_id) + + +def test_rename_api_key_wrong_user(enable_factory_create, db_session: db.Session): + """Test that rename_api_key raises 404 error when API key belongs to different user.""" + user1 = SharedUserFactory.create() + user2 = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user1, key_name="Original Key Name") + + with pytest.raises(apiflask.exceptions.HTTPError) as exc_info: + SharedApiKeyHandler(db_session).rename_api_key( + user_id=user2.shared_user_id, # Different user + api_key_id=api_key.shared_api_key_id, + key_name="New Key Name", + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "API key not found" + + db_session.refresh(api_key) + assert api_key.key_name == "Original Key Name" + + +def test_rename_api_key_same_name(enable_factory_create, db_session: db.Session): + """Test that rename_api_key works when setting the same name.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user, key_name="Same Key Name") + + renamed_api_key = SharedApiKeyHandler(db_session).rename_api_key( + user_id=user.shared_user_id, api_key_id=api_key.shared_api_key_id, key_name="Same Key Name" + ) + + assert renamed_api_key.key_name == "Same Key Name" + assert renamed_api_key.shared_api_key_id == api_key.shared_api_key_id + + +def test_rename_api_key_preserves_other_fields(enable_factory_create, db_session: db.Session): + """Test that rename_api_key only changes the key_name and preserves all other fields.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create( + shared_user=user, + key_name="Original Key Name", + is_active=False, + last_used=None, + ) + + original_api_key_id = api_key.shared_api_key_id + original_key_id = api_key.key_id + original_user_id = api_key.shared_user_id + original_is_active = api_key.is_active + original_last_used = api_key.last_used + original_created_at = api_key.created_at + + renamed_api_key = SharedApiKeyHandler(db_session).rename_api_key( + user_id=user.shared_user_id, + api_key_id=api_key.shared_api_key_id, + key_name="Updated Key Name", + ) + + assert renamed_api_key.key_name == "Updated Key Name" + assert renamed_api_key.shared_api_key_id == original_api_key_id + assert renamed_api_key.key_id == original_key_id + assert renamed_api_key.shared_user_id == original_user_id + assert renamed_api_key.is_active == original_is_active + assert renamed_api_key.last_used == original_last_used + assert renamed_api_key.created_at == original_created_at + + +def test_rename_api_key_multiple_keys_same_user(enable_factory_create, db_session: db.Session): + """Test that rename_api_key correctly identifies the right key when user has multiple keys.""" + user = SharedUserFactory.create() + api_key1 = SharedUserApiKeyFactory.create(shared_user=user, key_name="Key 1") + api_key2 = SharedUserApiKeyFactory.create(shared_user=user, key_name="Key 2") + + renamed_api_key = SharedApiKeyHandler(db_session).rename_api_key( + user_id=user.shared_user_id, api_key_id=api_key2.shared_api_key_id, key_name="Renamed Key 2" + ) + + assert renamed_api_key.shared_api_key_id == api_key2.shared_api_key_id + assert renamed_api_key.key_name == "Renamed Key 2" + + db_session.refresh(api_key1) + assert api_key1.key_name == "Key 1" + + +def test_rename_api_key_long_name(enable_factory_create, db_session: db.Session): + """Test that rename_api_key handles long key names correctly.""" + user = SharedUserFactory.create() + api_key = SharedUserApiKeyFactory.create(shared_user=user, key_name="Original Key Name") + + long_name = "A" * 255 + + renamed_api_key = SharedApiKeyHandler(db_session).rename_api_key( + user_id=user.shared_user_id, api_key_id=api_key.shared_api_key_id, key_name=long_name + ) + + assert renamed_api_key.key_name == long_name + assert len(renamed_api_key.key_name) == 255 diff --git a/backend/grants_shared/tests/grants_shared/auth/test_login_gov_jwt_auth.py b/backend/grants_shared/tests/grants_shared/auth/test_login_gov_jwt_auth.py new file mode 100644 index 0000000..156dcde --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/auth/test_login_gov_jwt_auth.py @@ -0,0 +1,268 @@ +from calendar import timegm +from datetime import datetime, timedelta, timezone + +import freezegun +import jwt +import pytest + +import grants_shared.auth.login_gov_jwt_auth as login_gov_jwt_auth +from grants_shared.auth.login_gov_jwt_auth import JwtValidationError, validate_token + +DEFAULT_CLIENT_ID = "urn:gov:unit-test" +DEFAULT_ISSUER = "http://localhost:3000" +DEFAULT_NONCE = "abc123" + + +def create_jwt( + user_id: str, + email: str, + expires_at: datetime, + issued_at: datetime, + not_before: datetime, + private_key: str | bytes, + issuer: str = DEFAULT_ISSUER, + audience: str = DEFAULT_CLIENT_ID, + acr: str = "urn:acr.login.gov:auth-only", + nonce: str = DEFAULT_NONCE, + kid: str = "test-key-id", +): + payload = { + "sub": user_id, + "iss": issuer, + "acr": acr, + "aud": audience, + "email": email, + "nonce": nonce, + # The jwt encode function automatically turns these datetime + # objects into a UTC timestamp integer + "exp": expires_at, + "iat": issued_at, + "nbf": not_before, + # These values aren't checked by anything at the moment + # but are a part of the token from login.gov + "jti": "abc123", + "at_hash": "abc123", + "c_hash": "abc123", + } + + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) + + +def test_validate_token_happy_path(login_gov_config, private_rsa_key): + user_id = "12345678-abc" + email = "fake@mail.com" + + token = create_jwt( + user_id=user_id, + email=email, + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + ) + + login_gov_user = validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + assert login_gov_user.user_id == user_id + assert login_gov_user.email == email + + +def test_validate_token_expired(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=30), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=30), + ) + + with pytest.raises(JwtValidationError, match="Expired Token"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_issued_at_future(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=1), + issued_at=datetime.now(tz=timezone.utc) + timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=30), + ) + + with pytest.raises(JwtValidationError, match="Token not yet valid"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_not_before_future(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) + timedelta(days=1), + ) + + with pytest.raises(JwtValidationError, match="Token not yet valid"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_unknown_issuer(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + issuer="fred", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + ) + + with pytest.raises(JwtValidationError, match="Unknown Issuer"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_unknown_audience(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + audience="fred", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + ) + + with pytest.raises(JwtValidationError, match="Unknown Audience"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_invalid_signature(login_gov_config, other_rsa_key_pair, monkeypatch): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + private_key=other_rsa_key_pair[0], # Create it with a different key + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + ) + + # Need to override the refresh logic so it doesn't try to reach out to anything + # We don't need to set the keys to anything else here. + def override_method(config): + pass + + monkeypatch.setattr(login_gov_jwt_auth, "_refresh_keys", override_method) + + with pytest.raises( + JwtValidationError, + match="Invalid Signature", + ): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_validate_token_key_found_on_refresh(login_gov_config, other_rsa_key_pair, monkeypatch): + user_id = "12345678-abcxyz" + email = "xfake@mail.com" + + token = create_jwt( + user_id=user_id, + email=email, + private_key=other_rsa_key_pair[0], # Create it with a different key + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + kid="a-different-key", + ) + + def override_method(config): + config.public_key_map = {"a-different-key": other_rsa_key_pair[1]} + + monkeypatch.setattr(login_gov_jwt_auth, "_refresh_keys", override_method) + + login_gov_user = validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + assert login_gov_user.user_id == user_id + assert login_gov_user.email == email + + +def test_validate_token_kid_not_found(login_gov_config, other_rsa_key_pair, monkeypatch): + user_id = "12345678-abc" + email = "fake@mail.com" + + token = create_jwt( + user_id=user_id, + email=email, + private_key=other_rsa_key_pair[0], # Create it with a different key + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + kid="a-different-key", + ) + + # Override it so nothing is found + def override_method(config): + config.public_key_map = {} + + monkeypatch.setattr(login_gov_jwt_auth, "_refresh_keys", override_method) + + with pytest.raises( + JwtValidationError, + match="No public key could be found for token", + ): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +@freezegun.freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_get_login_gov_client_assertion(login_gov_config, public_rsa_key): + client_assertion = login_gov_jwt_auth.get_login_gov_client_assertion(login_gov_config) + + # Turn the jwt back into a dict + # Validate with the public key + decoded_jwt = jwt.decode( + client_assertion, + key=public_rsa_key, + algorithms=["RS256"], + issuer=login_gov_config.client_id, + audience=login_gov_config.login_gov_token_endpoint, + ) + + assert decoded_jwt["iss"] == login_gov_config.client_id + assert decoded_jwt["sub"] == login_gov_config.client_id + assert decoded_jwt["aud"] == login_gov_config.login_gov_token_endpoint + assert decoded_jwt["jti"] is not None + # exp is 5 minutes from "now" + assert decoded_jwt["exp"] == timegm( + datetime.fromisoformat("2024-11-14 12:05:00+00:00").utctimetuple() + ) + + +def test_validate_token_invalid_nonce(login_gov_config, private_rsa_key): + token = create_jwt( + user_id="abc123", + email="mail@fake.com", + nonce="something_else", + private_key=private_rsa_key, + expires_at=datetime.now(tz=timezone.utc) + timedelta(days=30), + issued_at=datetime.now(tz=timezone.utc) - timedelta(days=1), + not_before=datetime.now(tz=timezone.utc) - timedelta(days=1), + ) + + with pytest.raises(JwtValidationError, match="Nonce does not match expected"): + validate_token(token, nonce=DEFAULT_NONCE, config=login_gov_config) + + +def test_get_final_logout_redirect_uri(login_gov_config): + + assert ( + login_gov_jwt_auth.get_final_logout_redirect_uri("example message", config=login_gov_config) + == "http://localhost:3000/final-logout?message=example+message" + ) + assert ( + login_gov_jwt_auth.get_final_logout_redirect_uri( + "bad message", error_description="it errored", config=login_gov_config + ) + == "http://localhost:3000/final-logout?message=bad+message&error_description=it+errored" + ) diff --git a/backend/grants_shared/tests/grants_shared/conftest.py b/backend/grants_shared/tests/grants_shared/conftest.py new file mode 100644 index 0000000..0ed0809 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/conftest.py @@ -0,0 +1,328 @@ +import os +import uuid + +import _pytest.monkeypatch +import boto3 +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from moto import mock_aws + +import tests.grants_shared.db.models.factories as factories +from grants_shared.adapters import db +from grants_shared.adapters.aws import S3Config +from grants_shared.auth.login_gov_jwt_auth import LoginGovConfig +from grants_shared.db.models.base import metadata +from grants_shared.db.models.lookup import sync_lookup_values +from grants_shared.util.local import load_local_env_vars +from tests.grants_shared.test_utils import db_testing + +# We import the test models we created so they're attached to the metadata +# and we can create them below in the db_client fixture +import tests.grants_shared.db_test_models.db_test_models # noqa: F401 isort:skip + + +@pytest.fixture(scope="session", autouse=True) +def env_vars(): + """ + Default environment variables for tests to be + based on the local.env file. These get set once + before all tests run. As "session" is the highest + scope, this will run before any other explicit fixtures + in a test. + + See: https://docs.pytest.org/en/6.2.x/fixture.html#autouse-order + + To set a different environment variable for a test, + use the monkeypatch fixture, for example: + + ```py + def test_example(monkeypatch): + monkeypatch.setenv("LOG_LEVEL", "debug") + ``` + + Several monkeypatch fixtures exists below for different + scope levels. + """ + load_local_env_vars() + + +################# +# Monkeypatch +################# + + +# From https://github.com/pytest-dev/pytest/issues/363 +@pytest.fixture(scope="session") +def monkeypatch_session(): + """ + Create a monkeypatch instance that can be used to + monkeypatch global environment, objects, and attributes + for the duration the test session. + """ + mpatch = _pytest.monkeypatch.MonkeyPatch() + yield mpatch + mpatch.undo() + + +# From https://github.com/pytest-dev/pytest/issues/363 +@pytest.fixture(scope="class") +def monkeypatch_class(): + """ + Create a monkeypatch instance that can be used to + monkeypatch global environment, objects, and attributes + for the duration of a test class. + """ + mpatch = _pytest.monkeypatch.MonkeyPatch() + yield mpatch + mpatch.undo() + + +# From https://github.com/pytest-dev/pytest/issues/363 +@pytest.fixture(scope="module") +def monkeypatch_module(): + mpatch = _pytest.monkeypatch.MonkeyPatch() + yield mpatch + mpatch.undo() + + +################# +# Database Setup +################# + + +@pytest.fixture(scope="session") +def db_schema_prefix(): + return f"test_{uuid.uuid4().int}_" + + +@pytest.fixture(scope="session") +def db_client(monkeypatch_session, db_schema_prefix) -> db.DBClient: + """ + Creates an isolated database for the test session. + + Creates a new empty PostgreSQL schema, creates all tables in the new schema + using SQLAlchemy, then returns a db.DBClient instance that can be used to + get connections or sessions to this database schema. The schema is dropped + after the test suite session completes. + """ + with db_testing.create_isolated_db(monkeypatch_session, db_schema_prefix) as db_client: + with db_client.get_connection() as conn, conn.begin(): + metadata.create_all(bind=conn) + + sync_lookup_values(db_client) + + yield db_client + + +@pytest.fixture +def db_session(db_client: db.DBClient) -> db.Session: + """ + Returns a database session connected to the schema used for the test session. + """ + with db_client.get_session() as session: + yield session + + +@pytest.fixture +def enable_factory_create(monkeypatch, db_session) -> db.Session: + """ + Allows the create method of factories to be called. By default, the create + throws an exception to prevent accidental creation of database objects for tests + that do not need persistence. This fixture only allows the create method to be + called for the current test. Each test that needs to call Factory.create should pull in + this fixture. + """ + monkeypatch.setattr(factories, "_db_session", db_session) + return db_session + + +################# +# AWS Mocking +################# + + +@pytest.fixture +def reset_aws_env_vars(monkeypatch): + # Reset the env vars so you can't accidentally connect + # to a real AWS account if you were doing some local testing + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + monkeypatch.delenv("AWS_S3_ENDPOINT_URL", raising=False) + monkeypatch.delenv("AWS_SQS_ENDPOINT_URL", raising=False) + monkeypatch.delenv("AWS_DYNAMODB_ENDPOINT_URL", raising=False) + monkeypatch.delenv("CDN_URL", raising=False) + monkeypatch.setattr("grants_shared.adapters.aws.aws_session._aws_config", None) + + +@pytest.fixture +def mock_s3(reset_aws_env_vars): + # https://docs.getmoto.org/en/stable/docs/configuration/index.html#whitelist-services + with mock_aws(config={"core": {"service_whitelist": ["s3"]}}): + yield boto3.resource("s3") + + +@pytest.fixture +def mock_s3_bucket_resource(mock_s3): + bucket = mock_s3.Bucket("local-mock-public-bucket") + bucket.create() + return bucket + + +@pytest.fixture +def mock_s3_bucket(mock_s3_bucket_resource): + return mock_s3_bucket_resource.name + + +@pytest.fixture +def other_mock_s3_bucket_resource(mock_s3): + # This second bucket exists for tests where we want there to be multiple buckets + # and/or test behavior when moving files between buckets. + bucket = mock_s3.Bucket("local-mock-draft-bucket") + bucket.create() + return bucket + + +@pytest.fixture +def other_mock_s3_bucket(other_mock_s3_bucket_resource): + return other_mock_s3_bucket_resource.name + + +@pytest.fixture +def mock_file_scan_s3_bucket_resource(mock_s3): + bucket = mock_s3.Bucket("local-mock-file-scan-bucket") + bucket.create() + return bucket + + +@pytest.fixture +def mock_file_scan_s3_bucket(mock_file_scan_s3_bucket_resource): + return mock_file_scan_s3_bucket_resource.name + + +@pytest.fixture +def s3_config(mock_s3_bucket, other_mock_s3_bucket, mock_file_scan_s3_bucket): + return S3Config( + PUBLIC_FILES_BUCKET=f"s3://{mock_s3_bucket}", + DRAFT_FILES_BUCKET=f"s3://{other_mock_s3_bucket}", + FILE_SCAN_BUCKET=f"s3://{mock_file_scan_s3_bucket}", + ) + + +@pytest.fixture +def ses_client(monkeypatch, reset_aws_env_vars): + """ + Create a mocked SESv2 client using moto. The mock is automatically cleaned up after the test. + + We call reset_aws_env_vars so that the aws_config gets remade for test that uses this + as we need the aws_config to be fresh. + """ + monkeypatch.setenv("IS_LOCAL_AWS", "0") + + with mock_aws(): + ses_client = boto3.client("sesv2", region_name="us-east-1") + ses_client.create_email_identity(EmailIdentity=os.getenv("AWS_SES_FROM_EMAIL")) + yield ses_client + + +@pytest.fixture +def mock_dynamodb(reset_aws_env_vars): + with mock_aws(config={"core": {"service_whitelist": ["dynamodb"]}}): + yield + + +@pytest.fixture +def file_scan_dynamodb_table(mock_dynamodb, monkeypatch): + dynamodb = boto3.client("dynamodb", region_name="us-east-1") + table_name = "test-local-virus-scan" + dynamodb.create_table( + TableName=table_name, + KeySchema=[ + {"AttributeName": "file_id", "KeyType": "HASH"}, + ], + AttributeDefinitions=[ + {"AttributeName": "file_id", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("FILE_SCAN_CACHE_TABLE_NAME", table_name) + return table_name + + +@pytest.fixture +def mock_sqs(reset_aws_env_vars): + with mock_aws(config={"core": {"service_whitelist": ["sqs"]}}): + yield + + +@pytest.fixture +def workflow_sqs_queue(mock_sqs, monkeypatch): + sqs = boto3.client("sqs", region_name="us-east-1") + # Create a default queue for tests + queue = sqs.create_queue(QueueName="test-workflow-queue") + # Set the env var of this queue so the SQSConfig picks it up + monkeypatch.setenv("WORKFLOW_QUEUE_URL", queue["QueueUrl"]) + return queue["QueueUrl"] + + +################# +# Auth +################# + + +def _generate_rsa_key_pair(): + # Rather than define a private/public key, generate one for the tests + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + private_key = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + public_key = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + return private_key, public_key + + +@pytest.fixture +def login_gov_config(public_rsa_key, private_rsa_key): + # Note this isn't session scoped so it gets remade + # for every test in the event of changes to it + return LoginGovConfig( + LOGIN_GOV_PUBLIC_KEY_MAP={"test-key-id": public_rsa_key}, + LOGIN_GOV_JWK_ENDPOINT="not_used", + LOGIN_GOV_ENDPOINT="http://localhost:3000", + LOGIN_GOV_CLIENT_ID="urn:gov:unit-test", + LOGIN_GOV_CLIENT_ASSERTION_PRIVATE_KEY=private_rsa_key, + LOGIN_GOV_AUTH_ENDPOINT="http://localhost:3000/auth", + LOGIN_GOV_TOKEN_ENDPOINT="http://localhost:3000/token", + LOGIN_GOV_LOGOUT_ENDPOINT="http://localhost:3000/logout", + LOGIN_FINAL_DESTINATION="http://localhost:3000/final", + LOGOUT_FINAL_DESTINATION="http://localhost:3000/final-logout", + ) + + +@pytest.fixture(scope="session") +def rsa_key_pair(): + return _generate_rsa_key_pair() + + +@pytest.fixture(scope="session") +def other_rsa_key_pair(): + return _generate_rsa_key_pair() + + +@pytest.fixture(scope="session") +def public_rsa_key(rsa_key_pair): + return rsa_key_pair[1] + + +@pytest.fixture(scope="session") +def private_rsa_key(rsa_key_pair): + return rsa_key_pair[0] diff --git a/backend/grants_shared/tests/grants_shared/db/__init__.py b/backend/grants_shared/tests/grants_shared/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/db/models/__init__.py b/backend/grants_shared/tests/grants_shared/db/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/db/models/factories.py b/backend/grants_shared/tests/grants_shared/db/models/factories.py new file mode 100644 index 0000000..3c5213b --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/factories.py @@ -0,0 +1,128 @@ +import random +from datetime import datetime + +import factory +import faker +from sqlalchemy.orm import scoped_session + +import grants_shared.adapters.db as db +import tests.grants_shared.db_test_models.db_test_models as db_test_models +from grants_shared.util import datetime_util + +fake = faker.Faker() + +_db_session: db.Session | None = None + + +def get_db_session() -> db.Session: + # _db_session is only set in the pytest fixture `enable_factory_create` + # so that tests do not unintentionally write to the database. + if _db_session is None: + raise Exception("""Factory db_session is not initialized. + + If your tests don't need to cover database behavior, consider + calling the `build()` method instead of `create()` on the factory to + not persist the generated model. + + If running tests that actually need data in the DB, pull in the + `enable_factory_create` fixture to initialize the db_session. + """) + + return _db_session + + +class Generators: + Now = factory.LazyFunction(datetime.now) + UtcNow = factory.LazyFunction(datetime_util.utcnow) + UuidObj = factory.Faker("uuid4", cast_to=None) + PhoneNumber = factory.Sequence(lambda n: f"123-456-{n:04}") + + +# The scopefunc ensures that the session gets cleaned up after each test +# it implicitly calls `remove()` on the session. +# see https://docs.sqlalchemy.org/en/20/orm/contextual.html +Session = scoped_session(lambda: get_db_session(), scopefunc=lambda: get_db_session()) + + +class BaseFactory(factory.alchemy.SQLAlchemyModelFactory): + + class Meta: + abstract = True + sqlalchemy_session = Session + sqlalchemy_session_persistence = "commit" + + +class ExampleTableFactory(BaseFactory): + class Meta: + model = db_test_models.ExampleTable + + example_id = Generators.UuidObj + + description = factory.Faker("paragraph", nb_sentences=1) + my_count = factory.Faker("random_int", min=1, max=10) + + friends = factory.RelatedFactoryList( + "tests.grants_shared.db.models.factories.FriendTableFactory", + factory_related_name="example", + size=lambda: random.randint(1, 3), + ) + + +class FriendTableFactory(BaseFactory): + class Meta: + model = db_test_models.FriendTable + + friend_id = Generators.UuidObj + + example = factory.SubFactory(ExampleTableFactory) + example_id = factory.LazyAttribute(lambda f: f.example.example_id) + + friend_types = factory.Faker( + "random_elements", + length=random.randint(1, 3), + elements=[f for f in db_test_models.FriendType], + unique=True, + ) + + +class SharedUserFactory(BaseFactory): + class Meta: + model = db_test_models.SharedUser + + shared_user_id = Generators.UuidObj + + +class SharedLinkExternalUserFactory(BaseFactory): + class Meta: + model = db_test_models.SharedLinkExternalUser + + link_external_user_id = Generators.UuidObj + external_user_id = Generators.UuidObj + shared_user = factory.SubFactory(SharedUserFactory) + shared_user_id = factory.LazyAttribute(lambda s: s.shared_user.shared_user_id) + email = factory.Faker("email") + + +class SharedLoginGovStateFactory(BaseFactory): + class Meta: + model = db_test_models.SharedLoginGovState + + shared_login_gov_state_id = Generators.UuidObj + nonce = Generators.UuidObj + + +class SharedUserApiKeyFactory(BaseFactory): + class Meta: + model = db_test_models.SharedUserApiKey + + shared_api_key_id = Generators.UuidObj + + shared_user = factory.SubFactory(SharedUserFactory) + shared_user_id = factory.LazyAttribute(lambda k: k.shared_user.shared_user_id) + + key_name = factory.Faker("sentence", nb_words=3) + key_id = factory.Sequence(lambda n: f"aws-api-gateway-key-{n:08d}") + + last_used = factory.Faker("date_time_between", start_date="-30d", end_date="now") + + is_active = True diff --git a/backend/grants_shared/tests/grants_shared/db/models/lookup/__init__.py b/backend/grants_shared/tests/grants_shared/db/models/lookup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup.py b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup.py new file mode 100644 index 0000000..d8e8c42 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup.py @@ -0,0 +1,118 @@ +from enum import StrEnum + +import pytest + +from grants_shared.adapters.db.type_decorators.postgres_type_decorators import LookupColumn +from grants_shared.db.models.base import metadata +from grants_shared.db.models.lookup import LookupConfig, LookupStr + + +class EnumX(StrEnum): + A = "A" + B = "B" + + +class EnumY(StrEnum): + C = "C" + D = "D" + + +class EnumZ(StrEnum): + # Str values overlap with X and Y + B = "B" + C = "C" + + +def test_lookup_config(): + config = LookupConfig( + [ + LookupStr(EnumX.A, 1), + LookupStr(EnumX.B, 2), + LookupStr(EnumY.C, 3), + LookupStr(EnumY.D, 4), + ] + ) + + assert config.get_int_for_enum(EnumX.A) == 1 + assert config.get_int_for_enum(EnumX.B) == 2 + assert config.get_int_for_enum(EnumY.C) == 3 + assert config.get_int_for_enum(EnumY.D) == 4 + + assert config.get_enum_for_int(1) == EnumX.A + assert config.get_enum_for_int(2) == EnumX.B + assert config.get_enum_for_int(3) == EnumY.C + assert config.get_enum_for_int(4) == EnumY.D + + +def test_lookup_config_duplicate_enum_str(): + with pytest.raises(AttributeError, match="Duplicate lookup_enum B defined"): + LookupConfig( + [ + LookupStr(EnumX.A, 1), + LookupStr(EnumX.B, 2), + LookupStr(EnumZ.B, 3), + ] + ) + + +def test_lookup_config_duplicate_lookup_val(): + with pytest.raises(AttributeError, match="Duplicate lookup_val 1 defined"): + LookupConfig( + [ + LookupStr(EnumX.A, 1), + LookupStr(EnumX.B, 1), + ] + ) + + +def test_lookup_config_missing_mapping(): + with pytest.raises( + AttributeError, + match="Lookup config must define a mapping for all enum values, the following were missing: {}", + ): + LookupConfig([LookupStr(EnumX.A, 1)]) + + +def test_lookup_config_negative(): + with pytest.raises( + AttributeError, + match="Only positive lookup_val values are allowed", + ): + LookupConfig([LookupStr(EnumX.A, -1)]) + + +def test_lookup_config_zero(): + with pytest.raises( + AttributeError, + match="Only positive lookup_val values are allowed", + ): + LookupConfig([LookupStr(EnumX.A, 0)]) + + +def test_lookup_columns_named_in_db_correctly(): + """ + If you are seeing this fail, here's an example of a valid one (context below):: + + pay_type: Mapped[PayType] = mapped_column( + "pay_type_id", # <<< Make sure to specifically define the column name as the first parameter + LookupColumn(LkAdditionalPayType), + ForeignKey(LkAdditionalPayType.additional_pay_type_id), + nullable=False, + ) + + We want our lookup columns to always be named `_id` in the DB + so that it's clear they are an ID pointing to another table and not the actual value. + + It's fine if our in-code value doesn't have ID, as we aren't working with an ID, + we are instead working with the enum directly. + + """ + + # This will validate the behavior of all columns in every table derived + # from the PostgresBase class which is everything we'll ever generate migrations for + for table in metadata.tables.values(): + for column in table.columns: + if isinstance(column.type, LookupColumn): + assert column.name.endswith( + "_id" + ), f"Lookup column {table.name}.{column.name} must be named with '_id' suffix" diff --git a/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup_registry.py b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup_registry.py new file mode 100644 index 0000000..2b2451b --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_lookup_registry.py @@ -0,0 +1,102 @@ +from enum import StrEnum + +import pytest +from sqlalchemy import Column, Integer + +from grants_shared.db.models.lookup import ( + Lookup, + LookupConfig, + LookupRegistry, + LookupStr, + LookupTable, +) + + +class TmpEnum(StrEnum): + A = "A" + B = "B" + C = "C" + + +class AnotherEnum(StrEnum): + D = "D" + E = "E" + + +TMP_LOOKUP_CONFIG = LookupConfig( + [ + LookupStr(TmpEnum.A, 1), + LookupStr(TmpEnum.B, 2), + LookupStr(TmpEnum.C, 3), + ] +) + + +class LkTmp(LookupTable): + __abstract__ = True # Mark it abstract so SQLAlchemy won't create it + __tablename__ = "lk_tmp" + + tmp_id: int = Column(Integer, primary_key=True) + + @classmethod + def from_lookup(cls, lookup: Lookup) -> LookupTable: + pass + + +def test_lookup_registry(): + try: + # This is the equivalent of adding @LookupRegistry.register_lookup(TmpLookup) + # on top of the LkTmp class, but without defining that at the module level + # that way we can reuse those classes. + LookupRegistry.register_lookup(TMP_LOOKUP_CONFIG)(LkTmp) + + sync_values = LookupRegistry.get_sync_values() + assert LkTmp in sync_values # Make sure it got added + assert sync_values[LkTmp] is TMP_LOOKUP_CONFIG + + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, 1) == TmpEnum.A + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, 2) == TmpEnum.B + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, 3) == TmpEnum.C + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, 0) is None + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, 4) is None + assert LookupRegistry.get_enum_for_lookup_int(LkTmp, None) is None + + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, TmpEnum.A) == 1 + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, TmpEnum.B) == 2 + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, TmpEnum.C) == 3 + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, AnotherEnum.D) is None + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, AnotherEnum.E) is None + assert LookupRegistry.get_lookup_int_for_enum(LkTmp, None) is None + finally: + # Because the registry is global, remove LkTmp + # so it doesn't affect other tests that run + del LookupRegistry._lookup_registry[LkTmp] + + # Verify we cleaned up properly + assert LkTmp not in LookupRegistry.get_sync_values() + + +def test_lookup_member_not_registered(): + with pytest.raises( + Exception, match="Table lk_tmp does not have a registered lookup_config via register_lookup" + ): + LookupRegistry.get_enum_for_lookup_int(LkTmp, 1) + + +def test_lookup_registry_duplicate_table(): + try: + LookupRegistry.register_lookup(TMP_LOOKUP_CONFIG)(LkTmp) + + with pytest.raises( + Exception, + match="Cannot attach lookup mapping to table lk_tmp, table already registered", + ): + LookupRegistry.register_lookup(TMP_LOOKUP_CONFIG)(LkTmp) + + finally: + # Because the registry is global, remove LkTmp + # so it doesn't affect other tests that run + del LookupRegistry._lookup_registry[LkTmp] + + # Verify we cleaned up properly + assert LkTmp not in LookupRegistry.get_sync_values() diff --git a/backend/grants_shared/tests/grants_shared/db/models/lookup/test_sync_lookup_values.py b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_sync_lookup_values.py new file mode 100644 index 0000000..6e11cb2 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/lookup/test_sync_lookup_values.py @@ -0,0 +1,119 @@ +import logging +import uuid +from enum import StrEnum + +import pytest +from sqlalchemy import inspect + +import grants_shared.adapters.db as db +from grants_shared.db.models.base import metadata +from grants_shared.db.models.lookup import LookupConfig, LookupRegistry, LookupStr, LookupTable +from grants_shared.db.models.lookup.sync_lookup_values import sync_lookup_values +from tests.grants_shared.db_test_models.db_test_models import LkExampleType +from tests.grants_shared.test_utils import db_testing + + +@pytest.fixture +def schema_no_lookup(monkeypatch) -> db.PostgresDBClient: + """ + Create a test schema, if it doesn't already exist, and drop it after the + test completes. + + This is similar to what the db_client fixture does but does not create any tables in the + schema. + """ + with db_testing.create_isolated_db(monkeypatch, f"test_lk_{uuid.uuid4().int}_") as db_client: + metadata.create_all(bind=db_client._engine) + # Skipping the sync that normally occurs to do in tests below + yield db_client + + +def validate_lookup_synced_to_table( + db_session, table: type[LookupTable], lookup_config: LookupConfig +): + db_lookup_values = db_session.query(table).all() + + assert len(db_lookup_values) == len(lookup_config.get_lookups()) + + primary_key = inspect(table).primary_key[0].name + + # Verify the values match by seeing if we can convert the descriptions + for db_lookup_value in db_lookup_values: + id_value = getattr(db_lookup_value, primary_key) + lookup_value = lookup_config.get_lookup_for_int(id_value) + + assert lookup_value is not None + assert db_lookup_value.description == lookup_value.get_description() + + +def test_sync_lookup_for_table_sanity(db_session): + # Note that db_session calls in a fixture that + # does the syncing, this test is making sure our tests + # are working with the correct data. + sync_values = LookupRegistry.get_sync_values() + + assert len(sync_values) > 0 + + # Verify all of our values are in the DB + for table, lookup in sync_values.items(): + validate_lookup_synced_to_table(db_session, table, lookup) + + +class NewExampleType(StrEnum): + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" + + +NEW_EXAMPLE_TYPE_CONFIG = LookupConfig( + [ + LookupStr(NewExampleType.A, 1), + LookupStr(NewExampleType.B, 2), + LookupStr(NewExampleType.C, 3), + LookupStr(NewExampleType.D, 4), + LookupStr(NewExampleType.E, 5), + LookupStr(NewExampleType.F, 6), + LookupStr(NewExampleType.G, 7), + ] +) + + +def test_sync_lookup_for_table(schema_no_lookup, caplog: pytest.LogCaptureFixture): + caplog.set_level(logging.INFO) + with schema_no_lookup.get_session() as db_session: + sync_values = LookupRegistry.get_sync_values() + for table in sync_values.keys(): + assert db_session.query(table).count() == 0 + + # Sync the lookup values to the DB + sync_lookup_values(schema_no_lookup) + + with schema_no_lookup.get_session() as db_session: + for table, lookup in sync_values.items(): + validate_lookup_synced_to_table(db_session, table, lookup) + + # Running sync again won't cause any change + caplog.clear() + sync_lookup_values(schema_no_lookup) + + assert "No modified lookup values for table lk_example_type" in caplog.text + + # Modify the lookup values used for one of the lookups + # in order to test that updates work, but reset it afterwards + # to avoid breaking other tests. + existing_config = LookupRegistry._lookup_registry[LkExampleType] + try: + LookupRegistry._lookup_registry[LkExampleType] = NEW_EXAMPLE_TYPE_CONFIG + caplog.clear() + sync_lookup_values(schema_no_lookup) + assert "Updated lookup value in table lk_example_type to" in caplog.text + + with schema_no_lookup.get_session() as db_session: + validate_lookup_synced_to_table(db_session, LkExampleType, NEW_EXAMPLE_TYPE_CONFIG) + + finally: + LookupRegistry._lookup_registry[LkExampleType] = existing_config diff --git a/backend/grants_shared/tests/grants_shared/db/models/test_base.py b/backend/grants_shared/tests/grants_shared/db/models/test_base.py new file mode 100644 index 0000000..bf3ea65 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/test_base.py @@ -0,0 +1,64 @@ +import uuid + +from tests.grants_shared.db_test_models.db_test_models import ExampleTable, ExampleType, FriendTable + + +def test_get_table_name(): + assert ExampleTable.get_table_name() == "example" + assert FriendTable.get_table_name() == "friend" + + +def test_for_json_example(): + + example = ExampleTable( + example_id=uuid.UUID("41fe3ffe-b211-415c-8f35-2250462e791d"), description="my description" + ) + + assert example.for_json() == { + "example_id": "41fe3ffe-b211-415c-8f35-2250462e791d", + "description": "my description", + "my_count": None, + "example_type": None, + # Haven't flushed to DB yet, these aren't set + "created_at": None, + "updated_at": None, + } + + example.my_count = 5 + example.description = "something else" + example.example_type = ExampleType.ANECDOTE + + assert example.for_json() == { + "example_id": "41fe3ffe-b211-415c-8f35-2250462e791d", + "description": "something else", + "my_count": 5, + "example_type": ExampleType.ANECDOTE, + # Haven't flushed to DB yet, these aren't set + "created_at": None, + "updated_at": None, + } + + assert ( + repr(example) + == ",created_at=None,updated_at=None)" + ) + + +def test_db_models(db_session): + """Sanity test that our DB models work that we use for testing / the DB setup works.""" + + with db_session.begin(): + example = ExampleTable(description="a description") + db_session.add(example) + + friend = FriendTable(example=example) + db_session.add(friend) + + db_session.refresh(example) + assert example.example_id is not None + assert example.created_at is not None + assert example.updated_at == example.created_at + + db_session.refresh(friend) + assert friend.friend_id is not None + assert friend.example is example diff --git a/backend/grants_shared/tests/grants_shared/db/models/type_decorators/__init__.py b/backend/grants_shared/tests/grants_shared/db/models/type_decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/db/models/type_decorators/test_postgres_type_decorators.py b/backend/grants_shared/tests/grants_shared/db/models/type_decorators/test_postgres_type_decorators.py new file mode 100644 index 0000000..4f7d69a --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db/models/type_decorators/test_postgres_type_decorators.py @@ -0,0 +1,88 @@ +from enum import StrEnum + +import pytest +from sqlalchemy import select, text + +from grants_shared.adapters.db.type_decorators.postgres_type_decorators import LookupColumn +from tests.grants_shared.db.models.factories import ExampleTableFactory, FriendTableFactory +from tests.grants_shared.db_test_models.db_test_models import ( + ExampleTable, + ExampleType, + FriendTable, + FriendType, + LkExampleType, +) + + +@pytest.mark.parametrize( + "example_type,db_value", + [(ExampleType.ANECDOTE, 2), (ExampleType.CASE_STUDY, 3), (None, None)], +) +def test_lookup_column_conversion( + db_session, enable_factory_create, example_type, db_value, db_schema_prefix +): + # Verify column works with factories + example = ExampleTableFactory.create(example_type=example_type) + assert example.example_type == example_type + + # Verify fetching from the DB works + db_session.expire_all() + + example_db = db_session.execute( + select(ExampleTable).where(ExampleTable.example_id == example.example_id) + ).scalar_one_or_none() + assert example_db.example_type == example_type + + # Verify what we stored in the DB is the integer + raw_db_value = db_session.execute( + text( + f"select example_type_id from {db_schema_prefix}grants_shared.{ExampleTable.get_table_name()} where example_id='{example.example_id}'" # nosec + ) + ).scalar() + assert raw_db_value == db_value + + +def test_lookup_column_conversion_through_association_proxy(db_session, enable_factory_create): + """Test that we can use an association proxy with the lookup values to make them act like a simple python set""" + + friend = FriendTableFactory.create(friend_types=[FriendType.BEST, FriendType.ACQUAINTANCE]) + + assert set(friend.friend_types) == {FriendType.BEST, FriendType.ACQUAINTANCE} + + # Verify fetching from the DB works + db_session.expire_all() + + example_db = db_session.execute( + select(FriendTable).where(FriendTable.friend_id == friend.friend_id) + ).scalar_one_or_none() + assert set(example_db.friend_types) == {FriendType.BEST, FriendType.ACQUAINTANCE} + + # We can update it + friend.friend_types = {FriendType.ACQUAINTANCE, FriendType.FRIEND_OF_FRIEND} + db_session.commit() + db_session.expire_all() + + example_db = db_session.execute( + select(FriendTable).where(FriendTable.friend_id == friend.friend_id) + ).scalar_one_or_none() + assert set(example_db.friend_types) == {FriendType.FRIEND_OF_FRIEND, FriendType.ACQUAINTANCE} + + +def test_lookup_column_bind_type_invalid(): + lookup_column = LookupColumn(LkExampleType) + with pytest.raises(Exception, match="Cannot convert value of type"): + lookup_column.process_bind_param("hello", None) + + class TestEnum(StrEnum): + ABSTRACT = "abstract" + + # Verify that just because an enum looks similar, if it's a different + # type it will also error + with pytest.raises(Exception, match="Cannot convert value of type"): + lookup_column.process_bind_param(TestEnum.ABSTRACT, None) + + +def test_lookup_column_process_result_type_invalid(): + lookup_column = LookupColumn(LkExampleType) + with pytest.raises(Exception, match="Cannot process value from DB of type"): + lookup_column.process_result_value("hello", None) diff --git a/backend/grants_shared/tests/grants_shared/db_test_models/__init__.py b/backend/grants_shared/tests/grants_shared/db_test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/db_test_models/db_test_models.py b/backend/grants_shared/tests/grants_shared/db_test_models/db_test_models.py new file mode 100644 index 0000000..da13f35 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/db_test_models/db_test_models.py @@ -0,0 +1,255 @@ +import uuid +from enum import StrEnum +from typing import Any + +from sqlalchemy import UUID, ForeignKey, and_ +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from grants_shared.adapters.db.type_decorators.postgres_type_decorators import LookupColumn +from grants_shared.db.models.auth_base_models import ( + BaseLinkExternalUser, + BaseLoginGovState, + BaseUser, + BaseUserApiKey, + BaseUserTokenSession, +) +from grants_shared.db.models.base import Base, TimestampMixin +from grants_shared.db.models.lookup import ( + Lookup, + LookupConfig, + LookupRegistry, + LookupStr, + LookupTable, +) + +# We don't have actual DB tables or DB migrations in grants_shared +# but want the ability to test DB interactions with tables. To do that +# we'll define SQLAlchemy models here. + +################################ +# Base tables +# +# Where the schemas for a given table are connected +################################ + + +class GrantsSharedSchemaTable(Base): + __abstract__ = True + + __table_args__: Any = {"schema": "grants_shared"} + + +class OtherSchemaTable(Base): + __abstract__ = True + + __table_args__: Any = {"schema": "other"} + + +class BaseLookupTable(GrantsSharedSchemaTable, LookupTable): + __abstract__ = True + + +################################ +# Lookup Tables +################################ + + +class ExampleType(StrEnum): + ABSTRACT = "abstract" + ANECDOTE = "anecdote" + CASE_STUDY = "case_study" + + +EXAMPLE_TYPE_CONFIG: LookupConfig[ExampleType] = LookupConfig( + [ + LookupStr(ExampleType.ABSTRACT, 1), + LookupStr(ExampleType.ANECDOTE, 2), + LookupStr(ExampleType.CASE_STUDY, 3), + ] +) + + +class FriendType(StrEnum): + BEST = "best" + ACQUAINTANCE = "acquaintance" + FRIEND_OF_FRIEND = "friend_of_friend" + + +FRIEND_TYPE_CONFIG: LookupConfig[FriendType] = LookupConfig( + [ + LookupStr(FriendType.BEST, 1), + LookupStr(FriendType.ACQUAINTANCE, 2), + LookupStr(FriendType.FRIEND_OF_FRIEND, 3), + ] +) + + +@LookupRegistry.register_lookup(EXAMPLE_TYPE_CONFIG) +class LkExampleType(BaseLookupTable, TimestampMixin): + __tablename__ = "lk_example_type" + + example_type_id: Mapped[int] = mapped_column(primary_key=True) + description: Mapped[str] + + @classmethod + def from_lookup(cls, lookup: Lookup) -> LkExampleType: + return LkExampleType( + example_type_id=lookup.lookup_val, description=lookup.get_description() + ) + + +@LookupRegistry.register_lookup(FRIEND_TYPE_CONFIG) +class LkFriendType(BaseLookupTable, TimestampMixin): + __tablename__ = "lk_friend_type" + + friend_type_id: Mapped[int] = mapped_column(primary_key=True) + description: Mapped[str] + + @classmethod + def from_lookup(cls, lookup: Lookup) -> LkFriendType: + return LkFriendType(friend_type_id=lookup.lookup_val, description=lookup.get_description()) + + +################################ +# Implemented Tables +################################ + + +class ExampleTable(GrantsSharedSchemaTable, TimestampMixin): + __tablename__ = "example" + + example_id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) + + description: Mapped[str] + my_count: Mapped[int | None] + + example_type: Mapped[ExampleType | None] = mapped_column( + "example_type_id", + LookupColumn(LkExampleType), + ForeignKey(LkExampleType.example_type_id), + ) + + friends: Mapped[list[FriendTable]] = relationship( + back_populates="example", uselist=True, cascade="all, delete-orphan" + ) + + +class FriendTable(OtherSchemaTable, TimestampMixin): + __tablename__ = "friend" + + friend_id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) + + example_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(ExampleTable.example_id)) + example: Mapped[ExampleTable] = relationship(ExampleTable) + + # Relationship link to the link_friend_type table + link_friend_types: Mapped[list[LinkFriendType]] = relationship( + back_populates="friend", uselist=True, cascade="all, delete-orphan" + ) + # Create an association proxy for each of the link table relationships + # https://docs.sqlalchemy.org/en/20/orm/extensions/associationproxy.html + # + # This lets us use these values as if they were just ordinary lists on a python + # object. For example:: + # + # friend.friend_types.add(FRIEND_TYPE.BEST) + # + # will add a row to the link_friend_type table itself + # and is still capable of using all of our column mapping code uneventfully. + friend_types: AssociationProxy[set[FriendType]] = association_proxy( + "link_friend_types", + "friend_type", + creator=lambda obj: LinkFriendType(friend_type=obj), + ) + + +class LinkFriendType(OtherSchemaTable, TimestampMixin): + __tablename__ = "link_friend_type" + + friend_id: Mapped[uuid.UUID] = mapped_column( + UUID, ForeignKey(FriendTable.friend_id), primary_key=True + ) + friend: Mapped[FriendTable] = relationship(FriendTable) + + friend_type: Mapped[FriendType] = mapped_column( + "friend_type_id", + LookupColumn(LkFriendType), + ForeignKey(LkFriendType.friend_type_id), + primary_key=True, + ) + + +class SharedUser(BaseUser, OtherSchemaTable, TimestampMixin): + __tablename__ = "shared_user" + + shared_user_id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) + + linked_login_gov_external_user: Mapped[SharedLinkExternalUser | None] = relationship( + "SharedLinkExternalUser", + primaryjoin=lambda: and_( + SharedLinkExternalUser.shared_user_id == SharedUser.shared_user_id, + ), + uselist=False, + viewonly=True, + ) + + api_keys: Mapped[list[SharedUserApiKey]] = relationship( + "SharedUserApiKey", back_populates="shared_user", uselist=True, cascade="all, delete-orphan" + ) + + @property + def email(self) -> str | None: + if self.linked_login_gov_external_user is not None: + return self.linked_login_gov_external_user.email + return None + + +class SharedLinkExternalUser(BaseLinkExternalUser, OtherSchemaTable, TimestampMixin): + __tablename__ = "shared_link_external_user" + + link_external_user_id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + shared_user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(SharedUser.shared_user_id), index=True + ) + shared_user: Mapped[SharedUser] = relationship(SharedUser) + + +class SharedUserTokenSession(BaseUserTokenSession, OtherSchemaTable, TimestampMixin): + __tablename__ = "shared_user_token_session" + + shared_user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(SharedUser.shared_user_id), primary_key=True + ) + shared_user: Mapped[SharedUser] = relationship(SharedUser) + + +class SharedLoginGovState(BaseLoginGovState, OtherSchemaTable, TimestampMixin): + """Table used to store temporary state during the OAuth login flow""" + + __tablename__ = "shared_login_gov_state" + + shared_login_gov_state_id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True) + + +class SharedUserApiKey(BaseUserApiKey, OtherSchemaTable, TimestampMixin): + """API Key table for user authentication to the API""" + + __tablename__ = "shared_user_api_key" + + shared_api_key_id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + + shared_user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(SharedUser.shared_user_id), index=True + ) + + shared_user: Mapped[SharedUser] = relationship( + SharedUser, back_populates="api_keys", uselist=False + ) + + def get_log_extra(self) -> dict[str, Any]: + """Get logging info""" + return { + "auth.shared_api_key_id": self.shared_api_key_id, + "auth.shared_user_id": self.shared_user_id, + } diff --git a/backend/grants_shared/tests/grants_shared/logs/__init__.py b/backend/grants_shared/tests/grants_shared/logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/logs/test_audit.py b/backend/grants_shared/tests/grants_shared/logs/test_audit.py new file mode 100644 index 0000000..19253a9 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/logs/test_audit.py @@ -0,0 +1,279 @@ +# +# Tests for grants_shared.logs.audit. +# + +# we import requests here as something in the import stack +# does a getaddrinfo. We want that to happen before we run +# our commands as the first time it runs it has to open/exec +# several different encoding files. +import requests # noqa: F401 isort:skip +import logging +import os +import pathlib +import signal +import socket +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from typing import Any + +import pytest + +import grants_shared.logs.audit as audit + +# Do not run these tests alongside the rest of the test suite since +# this tests adds an audit hook that interfere with other tests, +# and at the time of writing there isn't a known way to remove +# audit hooks. +pytestmark = pytest.mark.audit + + +@pytest.fixture(scope="session") +def init_audit_hook(): + audit.init() + + +test_audit_hook_data = [ + pytest.param(eval, ("1+1", None, None), [{"msg": "exec"}], id="eval"), + pytest.param(exec, ("1+1", None, None), [{"msg": "exec"}], id="exec"), + pytest.param( + open, + ("/dev/null", "w"), + [ + { + "msg": "open", + "audit.args.path": "/dev/null", + "audit.args.mode": "w", + } + ], + id="open", + ), + pytest.param( + os.rename, + ("/tmp/oldname", "/tmp/newname"), + [ + { + "msg": "os.rename", + "audit.args.src": "/tmp/oldname", + "audit.args.dst": "/tmp/newname", + } + ], + id="os.rename", + ), + pytest.param( + subprocess.Popen, + (["/usr/bin/git", "log", "HEAD~1..HEAD"],), + [ + { + "msg": "subprocess.Popen", + "audit.args.executable": "/usr/bin/git", + "audit.args.args": ["/usr/bin/git", "log", "HEAD~1..HEAD"], + } + ], + id="subprocess.Popen", + ), + pytest.param( + os.open, + ("/dev/null", os.O_RDWR | os.O_CREAT, 0o777), + [ + { + "msg": "open", + "audit.args.path": "/dev/null", + "audit.args.mode": None, + } + ], + id="os.open", + ), + pytest.param( + sys.addaudithook, + (lambda *args: None,), + [{"msg": "sys.addaudithook"}], + id="sys.addaudithook", + ), + pytest.param( + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect, + (("www.python.org", 80),), + [{"msg": "socket.connect", "audit.args.address": ("www.python.org", 80)}], + id="socket.connect", + ), + pytest.param( + socket.getaddrinfo, + ("www.python.org", 80), + [{"msg": "socket.getaddrinfo", "audit.args.host": "www.python.org", "audit.args.port": 80}], + id="socket.getaddrinfo", + ), + pytest.param( + urllib.request.urlopen, + ("https://www.python.org",), + # urllib.request.urlopen calls socket.getaddrinfo and socket.connect under the hood, + # both of which trigger audit log entries + [ + { + "msg": "urllib.Request", + "audit.args.url": "https://www.python.org", + "audit.args.method": "GET", + }, + { + "msg": "socket.getaddrinfo", + "audit.args.host": "www.python.org", + "audit.args.port": 443, + }, + { + "msg": "socket.connect", + }, + ], + id="urllib.request.urlopen", + ), +] + + +@pytest.mark.parametrize("func,args,expected_records", test_audit_hook_data) +def test_audit_hook( + init_audit_hook, + caplog: pytest.LogCaptureFixture, + func: Callable, + args: tuple[Any], + expected_records: list[dict[str, Any]], +): + caplog.clear() + + try: + func(*args) + except Exception: + pass + + assert len(caplog.records) == len(expected_records) + for record, expected_record in zip(caplog.records, expected_records, strict=True): + assert record.levelname == "AUDIT" + assert_record_match(record, expected_record) + + +def test_os_kill(init_audit_hook, caplog: pytest.LogCaptureFixture): + # Start a process to kill + process = subprocess.Popen("cat") + os.kill(process.pid, signal.SIGTERM) + + expected_records = [ + {"msg": "subprocess.Popen"}, + { + "msg": "os.kill", + "audit.args.pid": process.pid, + "audit.args.sig": signal.SIGTERM, + }, + ] + + assert len(caplog.records) == len(expected_records) + for record, expected_record in zip(caplog.records, expected_records, strict=True): + assert record.levelname == "AUDIT" + assert_record_match(record, expected_record) + + +def test_do_not_log_popen_env( + init_audit_hook, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("FOO", "SENSITIVE-DATA") + subprocess.Popen(["ls"], env=os.environ) + for record in caplog.records: + assert "SENSITIVE-DATA" not in str(record.__dict__) + + +def test_do_not_log_request_data( + init_audit_hook, + caplog: pytest.LogCaptureFixture, +): + data = urllib.parse.urlencode({"foo": "SENSITIVE-DATA"}).encode() + req = urllib.request.Request("https://www.python.org", data=data) + req.add_header("X-Bar", "SENSITIVE-DATA") + try: + urllib.request.urlopen(req) + except urllib.error.HTTPError: + pass + + for record in caplog.records: + assert "SENSITIVE-DATA" not in str(record.__dict__) + + +def test_repeated_audit_logs( + init_audit_hook, caplog: pytest.LogCaptureFixture, tmp_path: pathlib.Path +): + caplog.set_level(logging.INFO) + caplog.clear() + + for _ in range(1000): + open(tmp_path / "repeated-audit-logs", "w") + + expected_records = [ + {"msg": "open", "count": 1}, + {"msg": "open", "count": 2}, + {"msg": "open", "count": 3}, + {"msg": "open", "count": 4}, + {"msg": "open", "count": 5}, + {"msg": "open", "count": 6}, + {"msg": "open", "count": 7}, + {"msg": "open", "count": 8}, + {"msg": "open", "count": 9}, + {"msg": "open", "count": 10}, + {"msg": "open", "count": 20}, + {"msg": "open", "count": 30}, + {"msg": "open", "count": 40}, + {"msg": "open", "count": 50}, + {"msg": "open", "count": 60}, + {"msg": "open", "count": 70}, + {"msg": "open", "count": 80}, + {"msg": "open", "count": 90}, + {"msg": "open", "count": 100}, + {"msg": "open", "count": 200}, + {"msg": "open", "count": 300}, + {"msg": "open", "count": 400}, + {"msg": "open", "count": 500}, + {"msg": "open", "count": 600}, + {"msg": "open", "count": 700}, + {"msg": "open", "count": 800}, + {"msg": "open", "count": 900}, + {"msg": "open", "count": 1000}, + ] + + assert len(caplog.records) == len(expected_records) + for record, expected_record in zip(caplog.records, expected_records, strict=True): + assert record.levelname == "AUDIT" + assert_record_match(record, expected_record) + + +# Test utility data structure used by audit module +def test_least_recently_used_dict(): + lru_dict = audit.LeastRecentlyUsedDict(maxsize=4) + + assert lru_dict["a"] == 0 + assert len(lru_dict) == 0 + + lru_dict["a"] = 10 + lru_dict["b"] = 20 + lru_dict["c"] = 30 + lru_dict["d"] = 40 + + assert len(lru_dict) == 4 + assert tuple(lru_dict.items()) == (("a", 10), ("b", 20), ("c", 30), ("d", 40)) + assert lru_dict["a"] == 10 + assert lru_dict["b"] == 20 + assert lru_dict["c"] == 30 + assert lru_dict["d"] == 40 + assert lru_dict["e"] == 0 + assert len(lru_dict) == 4 + + lru_dict["a"] += 1 # Write existing a, move to end + assert len(lru_dict) == 4 + assert tuple(lru_dict.items()) == (("b", 20), ("c", 30), ("d", 40), ("a", 11)) + + lru_dict["f"] = 50 # Write new key f, and evict oldest b + lru_dict["c"] += 1 # Write existing c, move to end, and evict oldest d + lru_dict["g"] = 60 # Write new key g, and evict oldest d + assert len(lru_dict) == 4 + assert tuple(lru_dict.items()) == (("a", 11), ("f", 50), ("c", 31), ("g", 60)) + + +def assert_record_match(record: logging.LogRecord, expected_record: dict[str, Any]): + for key, value in expected_record.items(): + assert record.__dict__[key] == value diff --git a/backend/grants_shared/tests/grants_shared/logs/test_flask_logger.py b/backend/grants_shared/tests/grants_shared/logs/test_flask_logger.py new file mode 100644 index 0000000..baf8f4e --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/logs/test_flask_logger.py @@ -0,0 +1,164 @@ +import logging +import sys +import time + +import pytest +from flask import Flask + +import grants_shared.logs.flask_logger as flask_logger +from tests.grants_shared.test_utils.assertions import assert_dict_contains + + +@pytest.fixture +def logger(): + logger = logging.getLogger("grants_shared") + before_level = logger.level + + logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + yield logger + logger.setLevel(before_level) + logger.removeHandler(handler) + + +@pytest.fixture +def app(logger): + app = Flask("test_app_name") + + @app.get("/hello/") + def hello(name): + logging.getLogger("grants_shared.hello").info(f"hello, {name}!") + return "ok" + + flask_logger.init_app(logger, app, "test") + return app + + +test_request_lifecycle_logs_data = [ + pytest.param( + "/hello/jane", + [ + {"msg": "start request"}, + {"msg": "hello, jane!"}, + { + "msg": "end request", + "response.status_code": 200, + "response.content_length": 2, + "response.content_type": "text/html; charset=utf-8", + "response.mimetype": "text/html", + }, + ], + id="200", + ), + pytest.param( + "/notfound", + [ + {"msg": "start request"}, + { + "msg": "end request", + "response.status_code": 404, + "response.content_length": 207, + "response.content_type": "text/html; charset=utf-8", + "response.mimetype": "text/html", + }, + ], + id="404", + ), +] + + +@pytest.mark.parametrize( + "route,expected_extras", + test_request_lifecycle_logs_data, +) +def test_request_lifecycle_logs( + app: Flask, caplog: pytest.LogCaptureFixture, route, expected_extras +): + app.test_client().get(route) + + # Assert that the log messages are present + # There should be the route log message that is logged in the before_request handler + # as part of every request, followed by the log message in the route handler itself. + # then the log message in the after_request handler. + + assert len(caplog.records) == len(expected_extras) + for record, expected_extra in zip(caplog.records, expected_extras, strict=True): + assert_dict_contains(record.__dict__, expected_extra) + + +def test_app_context_extra_attributes(app: Flask, caplog: pytest.LogCaptureFixture): + # Assert that extra attributes related to the app context are present in all log records + expected_extra = {"app.name": "test_app_name", "app_domain": "test"} + + app.test_client().get("/hello/jane") + + assert len(caplog.records) > 0 + for record in caplog.records: + assert_dict_contains(record.__dict__, expected_extra) + + +def test_request_context_extra_attributes(app: Flask, caplog: pytest.LogCaptureFixture): + # Assert that the extra attributes related to the request context are present in all log records + expected_extra = { + "request.id": "", + "request.method": "GET", + "request.path": "/hello/jane", + "request.url_rule": "/hello/", + "request.query.up": "high", + "request.query.down": "low", + } + + app.test_client().get("/hello/jane?up=high&down=low") + + assert len(caplog.records) > 0 + for record in caplog.records: + assert_dict_contains(record.__dict__, expected_extra) + + +def test_add_extra_log_data_for_current_request(app: Flask, caplog: pytest.LogCaptureFixture): + @app.get("/pet/") + def pet(name): + flask_logger.add_extra_data_to_current_request_logs({"pet.name": name}) + logging.getLogger("test.pet").info(f"petting {name}") + return "ok" + + app.test_client().get("/pet/kitty") + + last_record = caplog.records[-1] + assert_dict_contains(last_record.__dict__, {"pet.name": "kitty"}) + + +def test_correlation_id_in_logs_when_header_present(app: Flask, caplog: pytest.LogCaptureFixture): + app.test_client().get("/hello/jane", headers={"X-Correlation-Id": "abc-123"}) + + assert len(caplog.records) > 0 + for record in caplog.records: + assert_dict_contains(record.__dict__, {"request.correlation_id": "abc-123"}) + + +def test_correlation_id_absent_from_logs_when_header_missing( + app: Flask, caplog: pytest.LogCaptureFixture +): + app.test_client().get("/hello/jane") + + assert len(caplog.records) > 0 + for record in caplog.records: + assert "request.correlation_id" not in record.__dict__ + + +def test_log_response_time(app: Flask, caplog: pytest.LogCaptureFixture): + @app.get("/sleep") + def sleep(): + time.sleep(0.01) # 0.01 s = 10 ms + return "ok" + + app.test_client().get("/sleep") + + last_record = caplog.records[-1] + assert "response.time_ms" in last_record.__dict__ + response_time_ms = last_record.__dict__["response.time_ms"] + expected_response_time_ms = 10 # ms + allowed_error = 5 # ms + + assert response_time_ms == pytest.approx(expected_response_time_ms, abs=allowed_error) diff --git a/backend/grants_shared/tests/grants_shared/logs/test_formatters.py b/backend/grants_shared/tests/grants_shared/logs/test_formatters.py new file mode 100644 index 0000000..4e4b447 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/logs/test_formatters.py @@ -0,0 +1,89 @@ +import json +import logging +import re +from datetime import datetime +from decimal import Decimal +from uuid import uuid4 + +import pytest + +import grants_shared.logs.formatters as formatters +from tests.grants_shared.test_utils.assertions import assert_dict_contains + + +def test_json_formatter(capsys: pytest.CaptureFixture): + logger = logging.getLogger("test_json_formatter") + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatters.JsonFormatter()) + logger.addHandler(console_handler) + + datetime_now = datetime.now() + date_now = datetime_now.date() + decimal_field = Decimal("12.34567") + uuid_field = uuid4() + set_field = {uuid4(), uuid4()} + list_field = [1, 2, 3, 4] + exception_field = ValueError("my exception message") + logger.warning( + "hello %s", + "interpolated_string", + extra={ + "foo": "bar", + "int_field": 5, + "bool_field": True, + "none_field": None, + "datetime_field": datetime_now, + "date_field": date_now, + "decimal_field": decimal_field, + "uuid_field": uuid_field, + "set_field": set_field, + "list_field": list_field, + "exception_field": exception_field, + }, + ) + + json_record = json.loads(capsys.readouterr().err) + + expected = { + "name": "test_json_formatter", + "message": "hello interpolated_string", + "formatted_msg": "hello interpolated_string", + "msg": "hello %s", + "levelname": "WARNING", + "levelno": 30, + "filename": "test_formatters.py", + "module": "test_formatters", + "funcName": "test_json_formatter", + "foo": "bar", + "int_field": 5, + "bool_field": True, + "none_field": None, + "datetime_field": datetime_now.isoformat(), + "date_field": date_now.isoformat(), + "decimal_field": str(decimal_field), + "uuid_field": str(uuid_field), + "set_field": [str(u) for u in set_field], + "list_field": list_field, + "exception_field": str(exception_field), + } + assert_dict_contains(json_record, expected) + logger.removeHandler(console_handler) + + +def test_human_readable_formatter(capsys: pytest.CaptureFixture): + logger = logging.getLogger("test_human_readable_formatter") + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatters.HumanReadableFormatter()) + logger.addHandler(console_handler) + + logger.warning("hello %s", "interpolated_string", extra={"foo": "bar"}) + + text = capsys.readouterr().err + created_time = text[:12] + rest = text[12:] + assert re.match(r"^\d{2}:\d{2}:\d{2}\.\d{3}", created_time) + assert ( + rest + == " test_human_readable_formatter \x1b[0m test_human_readable_formatter \x1b[31mWARNING \x1b[0m \x1b[31mhello interpolated_string \x1b[0m \x1b[34mfoo=bar\x1b[0m\n" + ) + logger.removeHandler(console_handler) diff --git a/backend/grants_shared/tests/grants_shared/logs/test_logging.py b/backend/grants_shared/tests/grants_shared/logs/test_logging.py new file mode 100644 index 0000000..4ffa925 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/logs/test_logging.py @@ -0,0 +1,98 @@ +import logging +import re + +import pytest + +import grants_shared.logs +import grants_shared.logs.formatters as formatters +from tests.grants_shared.test_utils.assertions import assert_dict_contains + + +@pytest.fixture +def init_test_logger(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch): + caplog.set_level(logging.DEBUG) + monkeypatch.setenv("LOG_FORMAT", "human-readable") + with grants_shared.logs.init("test_logging"): + yield + + +@pytest.mark.parametrize( + "log_format,expected_formatter", + [ + ("human-readable", formatters.HumanReadableFormatter), + ("json", formatters.JsonFormatter), + ], +) +def test_init(caplog: pytest.LogCaptureFixture, monkeypatch, log_format, expected_formatter): + caplog.set_level(logging.DEBUG) + monkeypatch.setenv("LOG_FORMAT", log_format) + + with grants_shared.logs.init("test_logging"): + records = caplog.records + assert len(records) == 2 + assert re.match( + r"^start test_logging: \w+ [0-9.]+ \w+, hostname \S+, pid \d+, user \d+\(\w+\)$", + records[0].message, + ) + assert re.match(r"^invoked as:", records[1].message) + + formatter_types = [type(handler.formatter) for handler in logging.root.handlers] + assert expected_formatter in formatter_types + + +def test_log_exception(init_test_logger, caplog): + logger = logging.getLogger(__name__) + + try: + raise Exception("example exception") + except Exception: + logger.exception( + "test log message %s", "example_arg", extra={"key1": "value1", "key2": "value2"} + ) + + last_record: logging.LogRecord = caplog.records[-1] + + assert last_record.message == "test log message example_arg" + assert last_record.funcName == "test_log_exception" + assert last_record.threadName == "MainThread" + assert last_record.exc_text.startswith("Traceback (most recent call last)") + assert last_record.exc_text.endswith("Exception: example exception") + assert last_record.__dict__["key1"] == "value1" + assert last_record.__dict__["key2"] == "value2" + + +@pytest.mark.parametrize( + "args,extra,expected", + [ + pytest.param( + ("ssn: 123456789",), + None, + {"message": "ssn: *********"}, + id="pii in msg", + ), + pytest.param( + ("pii",), + {"foo": "bar", "tin": "123456789", "dashed-ssn": "123-45-6789"}, + { + "message": "pii", + "foo": "bar", + "tin": "*********", + "dashed-ssn": "*********", + }, + id="pii in extra", + ), + pytest.param( + ("%s %s", "text", "123456789"), + None, + {"message": "text *********"}, + id="pii in interpolation args", + ), + ], +) +def test_mask_pii(init_test_logger, caplog: pytest.LogCaptureFixture, args, extra, expected): + logger = logging.getLogger(__name__) + + logger.info(*args, extra=extra) + + assert len(caplog.records) == 1 + assert_dict_contains(caplog.records[0].__dict__, expected) diff --git a/backend/grants_shared/tests/grants_shared/logs/test_pii.py b/backend/grants_shared/tests/grants_shared/logs/test_pii.py new file mode 100644 index 0000000..20107b1 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/logs/test_pii.py @@ -0,0 +1,67 @@ +import logging + +import pytest + +import grants_shared.logs.pii as pii + + +@pytest.mark.parametrize( + "input,expected", + [ + ("", ""), + ("1234", "1234"), + (1234, 1234), + (None, None), + ("hostname ip-10-11-12-134.ec2.internal", "hostname ip-10-11-12-134.ec2.internal"), + ({}, {}), + ("123456789", "*********"), + (123456789, "*********"), + ("123-45-6789", "*********"), + ("123456789 test", "********* test"), + ("test 123456789", "test *********"), + ("test 123456789 test", "test ********* test"), + ("test=999000000.", "test=*********."), + ("test=999000000,", "test=*********,"), + (999000000.5, 999000000.5), + ({"a": "x", "b": "999000000"}, "{'a': 'x', 'b': '*********'}"), + ], +) +def test_mask_pii(input, expected): + assert pii._mask_pii(input) == expected + + +@pytest.mark.parametrize( + "input_value,expected_output", + [ + # Basic SSN patterns that should be masked + ("123456789", "*********"), + ("123-45-6789", "*********"), + # IP addresses that should not be masked + ("ip-10-11-12-134", "ip-10-11-12-134"), + # Floating point numbers that should not be masked + ("5.999000000", "5.999000000"), + ("999000000.5", "999000000.5"), + ("0.999000000", "0.999000000"), + ("999.000000", "999.000000"), + # Mixed content + ("SSN: 123456789 Amount: 999000000.5", "SSN: ********* Amount: 999000000.5"), + ("IP: ip-10-11-12-134 SSN: 123-45-6789", "IP: ip-10-11-12-134 SSN: *********"), + ], +) +def test_mask_pii_logging_floats(input_value, expected_output): + # Create a LogRecord with the test value + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg=input_value, + args=(), + exc_info=None, + ) + + # Apply PII masking + pii.mask_pii(record) + + # Check that the message was properly masked + assert record.msg == expected_output diff --git a/backend/grants_shared/tests/grants_shared/pagination/__init__.py b/backend/grants_shared/tests/grants_shared/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/pagination/test_pagination_models.py b/backend/grants_shared/tests/grants_shared/pagination/test_pagination_models.py new file mode 100644 index 0000000..b91e66d --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/pagination/test_pagination_models.py @@ -0,0 +1,27 @@ +import pytest + +from grants_shared.pagination.pagination_models import PaginationInfo, PaginationParams + + +@pytest.mark.parametrize( + "total_records, page_size, expected_total_records, expected_total_pages", + [ + # Scenarios under 10k total records + (517, 25, 517, 21), + (5101, 1, 5101, 5101), + (9999, 1000, 9999, 10), + # Scenarios over 10k total records + (13000, 4000, 10000, 3), + (15000, 1000, 10000, 10), + (10001, 25, 10000, 400), + (10001, 1, 10000, 10000), + ], +) +def test_from_search_response( + total_records, page_size, expected_total_records, expected_total_pages +): + pagination_params = PaginationParams(page_offset=1, page_size=page_size) + info = PaginationInfo.from_search_response(pagination_params, total_records) + + assert info.total_records == expected_total_records + assert info.total_pages == expected_total_pages diff --git a/backend/grants_shared/tests/grants_shared/pagination/test_pagination_schema.py b/backend/grants_shared/tests/grants_shared/pagination/test_pagination_schema.py new file mode 100644 index 0000000..ea86263 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/pagination/test_pagination_schema.py @@ -0,0 +1,99 @@ +import pytest + +from grants_shared.api.schemas.extension import Schema, SchemaValidationError, fields +from grants_shared.pagination.pagination_schema import generate_pagination_schema +from tests.grants_shared.api.schemas.schema_validation_utils import validate_expected_errors + + +class ExampleSchema(Schema): + + pagination = fields.Nested( + generate_pagination_schema( + "ExamplePagination1Schema", order_by_fields=["field1", "field2"] + ), + required=False, + ) + + pagination_with_defaults = fields.Nested( + generate_pagination_schema( + "ExamplePagination2Schema", + order_by_fields=["field_a", "field_b", "field_c"], + default_sort_order=[{"order_by": "field_a", "sort_direction": "ascending"}], + default_page_size=5, + default_page_offset=1, + ), + required=False, + ) + + +@pytest.mark.parametrize( + "data", + [ + {}, + { + "pagination": { + "sort_order": [{"order_by": "field1", "sort_direction": "ascending"}], + "page_size": 5, + "page_offset": 1, + } + }, + { + "pagination": { + "sort_order": [ + {"order_by": "field1", "sort_direction": "ascending"}, + {"order_by": "field2", "sort_direction": "descending"}, + ], + "page_size": 25, + "page_offset": 50, + } + }, + # One with defaults is perfectly valid to be empty + {"pagination_with_defaults": {}}, + ], +) +def test_pagination_schema_valid_requests(data): + issues = ExampleSchema().validate(data) + assert len(issues) == 0 + + +@pytest.mark.parametrize( + "data,expected_errors", + [ + # Missing required + ( + {"pagination": {}}, + { + "pagination.sort_order": SchemaValidationError.REQUIRED, + "pagination.page_size": SchemaValidationError.REQUIRED, + "pagination.page_offset": SchemaValidationError.REQUIRED, + }, + ), + # Empty sort_order + ( + {"pagination": {"sort_order": [], "page_size": 5, "page_offset": 1}}, + {"pagination.sort_order": SchemaValidationError.MIN_OR_MAX_LENGTH}, + ), + # Missing sort_order params + ( + {"pagination": {"sort_order": [{}], "page_size": 5, "page_offset": 1}}, + { + "pagination.sort_order.0.order_by": SchemaValidationError.REQUIRED, + "pagination.sort_order.0.sort_direction": SchemaValidationError.REQUIRED, + }, + ), + # Invalid order_by + ( + { + "pagination": { + "sort_order": [{"order_by": "not_a_field", "sort_direction": "ascending"}], + "page_size": 5, + "page_offset": 1, + } + }, + {"pagination.sort_order.0.order_by": SchemaValidationError.INVALID_CHOICE}, + ), + ], +) +def test_pagination_schema_invalid_values(data, expected_errors): + issues = ExampleSchema().validate(data) + validate_expected_errors(issues, expected_errors) diff --git a/backend/grants_shared/tests/grants_shared/pagination/test_paginator.py b/backend/grants_shared/tests/grants_shared/pagination/test_paginator.py new file mode 100644 index 0000000..28a34de --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/pagination/test_paginator.py @@ -0,0 +1,142 @@ +import pytest +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from grants_shared.pagination.paginator import Paginator +from tests.grants_shared.db.models.factories import ExampleTableFactory +from tests.grants_shared.db_test_models.db_test_models import ( + ExampleTable, + ExampleType, + FriendTable, + LinkFriendType, +) + +DEFAULT_EXAMPLE_PARAMS = { + "description": "opportunity of a lifetime", + "my_count": 111, + "example_type": ExampleType.ANECDOTE, +} + + +@pytest.fixture +def create_examples(db_session, enable_factory_create): + # As pagination needs to have exact amounts of records in the table to properly + # test, clear out all prior data first. + with db_session.no_autoflush: + records = db_session.scalars(select(ExampleTable).options(selectinload("*"))) + + for record in records: + db_session.delete(record) + + db_session.commit() + + # 5 with the default params + ExampleTableFactory.create_batch(5, **DEFAULT_EXAMPLE_PARAMS) + + # 4 with a different description + params = DEFAULT_EXAMPLE_PARAMS | {"description": "something else"} + ExampleTableFactory.create_batch(4, **params) + + # 3 with a different count + params = DEFAULT_EXAMPLE_PARAMS | {"my_count": 234} + ExampleTableFactory.create_batch(3, **params) + + # 2 that aren't drafts + params = DEFAULT_EXAMPLE_PARAMS | {"example_type": ExampleType.CASE_STUDY} + ExampleTableFactory.create_batch(2, **params) + + # 1 that is different in all ways + params = { + "description": "something else", + "my_count": 234, + "example_type": ExampleType.CASE_STUDY, + } + ExampleTableFactory.create_batch(1, **params) + + +def test_paginator(db_session, create_examples): + # A base "select * from example_table" query + base_stmt = select(ExampleTable) + + # Verify that with no additional filters, we get everything + paginator = Paginator(ExampleTable, base_stmt, db_session, page_size=6) + assert paginator.page_size == 6 + assert paginator.total_pages == 3 + assert paginator.total_records == 15 + + # The pages are generated at the expected length + assert len(paginator.page_at(1)) == 6 + assert len(paginator.page_at(2)) == 6 + assert len(paginator.page_at(3)) == 3 + assert len(paginator.page_at(4)) == 0 + + # Verify when filtering by description + stmt = base_stmt.filter(ExampleTable.description == "something else") + paginator = Paginator(ExampleTable, stmt, db_session, page_size=10) + assert paginator.page_size == 10 + assert paginator.total_pages == 1 + assert paginator.total_records == 5 + + assert len(paginator.page_at(1)) == 5 + assert len(paginator.page_at(2)) == 0 + + # Verify when filtering by my_count + stmt = base_stmt.filter(ExampleTable.my_count == 234) + paginator = Paginator(ExampleTable, stmt, db_session, page_size=1) + assert paginator.page_size == 1 + assert paginator.total_pages == 4 + assert paginator.total_records == 4 + + assert len(paginator.page_at(1)) == 1 + assert len(paginator.page_at(2)) == 1 + assert len(paginator.page_at(3)) == 1 + assert len(paginator.page_at(4)) == 1 + assert len(paginator.page_at(5)) == 0 + + # Verify when filtering by example_type + stmt = base_stmt.filter(ExampleTable.example_type == ExampleType.CASE_STUDY) + paginator = Paginator(ExampleTable, stmt, db_session, page_size=100) + assert paginator.page_size == 100 + assert paginator.total_pages == 1 + assert paginator.total_records == 3 + + assert len(paginator.page_at(1)) == 3 + assert len(paginator.page_at(2)) == 0 + + # Verify when filtering by all fields + stmt = base_stmt.filter( + ExampleTable.description == "something else", + ExampleTable.my_count == 234, + ExampleTable.example_type == ExampleType.CASE_STUDY, + ) + paginator = Paginator(ExampleTable, stmt, db_session) + assert paginator.page_size == 25 + assert paginator.total_pages == 1 + assert paginator.total_records == 1 + + assert len(paginator.page_at(1)) == 1 + assert len(paginator.page_at(2)) == 0 + + # Verify when filtering to zero results + stmt = base_stmt.filter(ExampleTable.description == "something that won't be found") + paginator = Paginator(ExampleTable, stmt, db_session) + assert paginator.page_size == 25 + assert paginator.total_pages == 0 + assert paginator.total_records == 0 + + assert len(paginator.page_at(1)) == 0 + + # Verify when adding joins, the counts continue to be correct + # If we didn't have distinct in the count function, we'd end up with + # every example being counted extra for each friend table value + stmt = base_stmt.join(FriendTable).join(LinkFriendType) + paginator = Paginator(ExampleTable, stmt, db_session, page_size=6) + assert paginator.page_size == 6 + assert paginator.total_pages == 3 + assert paginator.total_records == 15 + + +@pytest.mark.parametrize("page_size", [0, -1, -2]) +def test_page_size_zero_or_negative(db_session, page_size): + with pytest.raises(ValueError, match="Page size must be at least 1"): + Paginator(ExampleTable, select(ExampleTable), db_session, page_size) diff --git a/backend/grants_shared/tests/grants_shared/pagination/test_sorting_util.py b/backend/grants_shared/tests/grants_shared/pagination/test_sorting_util.py new file mode 100644 index 0000000..7ded64d --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/pagination/test_sorting_util.py @@ -0,0 +1,130 @@ +import pytest +from sqlalchemy import select + +from grants_shared.pagination.pagination_models import SortDirection, SortOrderParams +from grants_shared.pagination.sorting_util import apply_sorting +from tests.grants_shared.db_test_models.db_test_models import ExampleTable + + +def validate_order_by(stmt, expected_sql: str): + raw_sql = stmt.compile(compile_kwargs={"literal_binds": True}) + assert expected_sql in str(raw_sql) + + +COLUMN_MAPPING = { + "example_id": ExampleTable.example_id, + "description": ExampleTable.description, + "my_count": ExampleTable.my_count, +} + + +def test_apply_sorting_with_mapping_nulls_last(): + base_stmt = select(ExampleTable) + + # Sort by one field + result = apply_sorting( + base_stmt, + [SortOrderParams(order_by="example_id", sort_direction=SortDirection.ASCENDING)], + COLUMN_MAPPING, + nulls_last=True, + ) + validate_order_by(result, "ORDER BY grants_shared.example.example_id ASC NULLS LAST") + + # Sort by two fields + result = apply_sorting( + base_stmt, + [ + SortOrderParams(order_by="description", sort_direction=SortDirection.DESCENDING), + SortOrderParams(order_by="my_count", sort_direction=SortDirection.ASCENDING), + ], + COLUMN_MAPPING, + nulls_last=True, + ) + validate_order_by( + result, + "ORDER BY grants_shared.example.description DESC NULLS LAST, grants_shared.example.my_count ASC NULLS LAST", + ) + + # Sort by three fields + result = apply_sorting( + base_stmt, + [ + SortOrderParams(order_by="description", sort_direction=SortDirection.ASCENDING), + SortOrderParams(order_by="example_id", sort_direction=SortDirection.DESCENDING), + SortOrderParams(order_by="my_count", sort_direction=SortDirection.DESCENDING), + ], + COLUMN_MAPPING, + nulls_last=True, + ) + validate_order_by( + result, + "ORDER BY grants_shared.example.description ASC NULLS LAST, grants_shared.example.example_id DESC NULLS LAST, grants_shared.example.my_count DESC NULLS LAST", + ) + + +def test_apply_sorting_with_mapping_no_nulls_last(): + result = apply_sorting( + select(ExampleTable), + [ + SortOrderParams(order_by="description", sort_direction=SortDirection.DESCENDING), + SortOrderParams(order_by="my_count", sort_direction=SortDirection.ASCENDING), + ], + COLUMN_MAPPING, + ) + validate_order_by( + result, + "ORDER BY grants_shared.example.description DESC, grants_shared.example.my_count ASC", + ) + # No NULLS LAST should be emitted when the flag is left at its default + assert "NULLS LAST" not in str(result.compile()) + + +def test_apply_sorting_with_model_getattr(): + # Passing the model class resolves columns via getattr, no NULLS LAST by default + result = apply_sorting( + select(ExampleTable), + [ + SortOrderParams(order_by="example_id", sort_direction=SortDirection.ASCENDING), + SortOrderParams(order_by="my_count", sort_direction=SortDirection.DESCENDING), + ], + ExampleTable, + ) + validate_order_by( + result, + "ORDER BY grants_shared.example.example_id ASC, grants_shared.example.my_count DESC", + ) + assert "NULLS LAST" not in str(result.compile()) + + +def test_apply_sorting_with_model_getattr_nulls_last(): + result = apply_sorting( + select(ExampleTable), + [SortOrderParams(order_by="my_count", sort_direction=SortDirection.ASCENDING)], + ExampleTable, + nulls_last=True, + ) + validate_order_by(result, "ORDER BY grants_shared.example.my_count ASC NULLS LAST") + + +def test_apply_sorting_empty_sort_order(): + # An empty sort order should not add an ORDER BY clause + result = apply_sorting(select(ExampleTable), [], COLUMN_MAPPING) + assert "ORDER BY" not in str(result.compile()) + + +def test_apply_sorting_missing_column_mapping(): + with pytest.raises(ValueError, match="not found in column mapping"): + apply_sorting( + select(ExampleTable), + [SortOrderParams(order_by="not_a_field", sort_direction=SortDirection.ASCENDING)], + COLUMN_MAPPING, + ) + + +def test_apply_sorting_missing_model_attribute(): + with pytest.raises(AttributeError): + apply_sorting( + select(ExampleTable), + [SortOrderParams(order_by="not_a_field", sort_direction=SortDirection.ASCENDING)], + ExampleTable, + ) diff --git a/backend/grants_shared/tests/grants_shared/services/__init__.py b/backend/grants_shared/tests/grants_shared/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/services/users/__init__.py b/backend/grants_shared/tests/grants_shared/services/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/services/users/test_login_gov_callback_handler.py b/backend/grants_shared/tests/grants_shared/services/users/test_login_gov_callback_handler.py new file mode 100644 index 0000000..70d1dd3 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/services/users/test_login_gov_callback_handler.py @@ -0,0 +1,319 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import jwt +import pytest +from apiflask import HTTPError +from sqlalchemy import select + +import grants_shared.auth.login_gov_jwt_auth as login_gov_jwt_auth +from grants_shared.adapters.oauth.login_gov.mock_login_gov_oauth_client import ( + MockLoginGovOauthClient, +) +from grants_shared.adapters.oauth.oauth_client_models import OauthTokenResponse +from grants_shared.auth.api_jwt_auth import ApiJwtConfig, JwtAuth +from grants_shared.services.users.login_gov_callback_handler import LoginGovDataContainer +from tests.grants_shared.db.models.factories import ( + SharedLinkExternalUserFactory, + SharedLoginGovStateFactory, +) +from tests.grants_shared.db_test_models.db_test_models import ( + SharedLinkExternalUser, + SharedLoginGovState, + SharedUserTokenSession, +) +from tests.grants_shared.test_utils.auth_handler import AuthHandler +from tests.grants_shared.test_utils.login_gov_callback_handler import LoginGovCallbackHandler + +# These match the values on the login_gov_config fixture in conftest.py +DEFAULT_ISSUER = "http://localhost:3000" +DEFAULT_CLIENT_ID = "urn:gov:unit-test" +DEFAULT_NONCE = "abc123" + + +def create_id_token( + user_id: str, + private_key: str | bytes, + email: str = "fake@mail.com", + nonce: str = DEFAULT_NONCE, + issuer: str = DEFAULT_ISSUER, + audience: str = DEFAULT_CLIENT_ID, + kid: str = "test-key-id", +): + """Create an id_token in roughly the format login.gov returns from the token endpoint""" + payload = { + "sub": user_id, + "iss": issuer, + "aud": audience, + "email": email, + "nonce": nonce, + # The jwt encode function automatically turns these datetime + # objects into a UTC timestamp integer + "exp": datetime.now(tz=timezone.utc) + timedelta(days=30), + "iat": datetime.now(tz=timezone.utc) - timedelta(days=1), + "nbf": datetime.now(tz=timezone.utc) - timedelta(days=1), + "jti": "abc123", + "acr": "urn:acr.login.gov:auth-only", + } + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) + + +@pytest.fixture +def jwt_config(private_rsa_key, public_rsa_key): + return ApiJwtConfig( + API_JWT_PRIVATE_KEY=private_rsa_key, + API_JWT_PUBLIC_KEY=public_rsa_key, + ) + + +@pytest.fixture +def login_gov_callback_handler(db_session, jwt_config): + auth_handler = AuthHandler(db_session) + jwt_auth = JwtAuth(auth_handler, jwt_config) + return LoginGovCallbackHandler(auth_handler, jwt_auth) + + +@pytest.fixture +def mock_oauth_client(monkeypatch): + """Swap the real login.gov client for a mock one in the callback handler""" + client = MockLoginGovOauthClient() + monkeypatch.setattr( + "grants_shared.services.users.login_gov_callback_handler.get_login_gov_client", + lambda: client, + ) + return client + + +@pytest.fixture +def set_login_gov_config(monkeypatch, login_gov_config): + """Set the module-level login.gov config the handler reads via get_config()""" + monkeypatch.setattr(login_gov_jwt_auth, "_config", login_gov_config) + return login_gov_config + + +########################################## +# handle_callback_request +########################################## + + +def test_handle_callback_request(enable_factory_create, db_session, login_gov_callback_handler): + login_gov_state = SharedLoginGovStateFactory.create() + query = { + "code": "1234", + "state": str(login_gov_state.shared_login_gov_state_id), + } + login_gov_data_container = login_gov_callback_handler.handle_callback_request(query) + assert login_gov_data_container.code == query["code"] + assert login_gov_data_container.nonce == str(login_gov_state.nonce) + + # The state should have been deleted so it can't be reused + remaining_state = db_session.execute( + select(SharedLoginGovState).where( + SharedLoginGovState.shared_login_gov_state_id + == login_gov_state.shared_login_gov_state_id + ) + ).scalar_one_or_none() + assert remaining_state is None + + +@pytest.mark.parametrize( + "query,expected_status,expected_message", + [ + # access_denied means the user cancelled/declined, so we send back a 401 + ( + {"error": "access_denied", "error_description": "user declined"}, + 401, + "User declined to login", + ), + # any other error indicates a misconfiguration on our end, so it's a 500 + ( + {"error": "invalid_request", "error_description": "something is misconfigured"}, + 500, + "invalid_request something is misconfigured", + ), + ], +) +def test_handle_callback_request_invalid_callback_params( + login_gov_callback_handler, query, expected_status, expected_message +): + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_callback_request(query) + + assert exc_info.value.status_code == expected_status + assert exc_info.value.message == expected_message + + +def test_handle_callback_request_code_none(login_gov_callback_handler): + query = {"state": str(uuid.uuid4())} + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_callback_request(query) + + assert exc_info.value.status_code == 422 + assert exc_info.value.message == "Missing code in request" + + +def test_handle_callback_request_state_none(login_gov_callback_handler): + query = {"code": "1234"} + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_callback_request(query) + + assert exc_info.value.status_code == 422 + assert exc_info.value.message == "Missing state in request" + + +def test_handle_callback_request_invalid_uuid_state(login_gov_callback_handler): + query = {"code": "1234", "state": "not-a-uuid"} + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_callback_request(query) + + assert exc_info.value.status_code == 422 + assert exc_info.value.message == "Invalid OAuth state value" + + +def test_handle_callback_request_login_gov_state_none(login_gov_callback_handler): + # A valid UUID that doesn't correspond to any stored state + query = {"code": "1234", "state": str(uuid.uuid4())} + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_callback_request(query) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "OAuth state not found" + + +########################################## +# handle_token +########################################## + + +def test_handle_token_succeeds( + enable_factory_create, + db_session, + login_gov_callback_handler, + mock_oauth_client, + set_login_gov_config, + private_rsa_key, +): + external_user_id = str(uuid.uuid4()) + code = str(uuid.uuid4()) + id_token = create_id_token( + user_id=external_user_id, + email="Fake_User@Mail.com", + private_key=private_rsa_key, + ) + mock_oauth_client.add_token_response( + code, + OauthTokenResponse( + id_token=id_token, access_token="fake_token", token_type="Bearer", expires_in=300 + ), + ) + + response = login_gov_callback_handler.handle_token( + LoginGovDataContainer(code=code, nonce=DEFAULT_NONCE) + ) + + assert response.is_user_new is True + assert response.token is not None + + # The external user should have been created with a lowercased email + external_user = db_session.execute( + select(SharedLinkExternalUser).where( + SharedLinkExternalUser.external_user_id == external_user_id + ) + ).scalar_one() + assert external_user.email == "fake_user@mail.com" + + # A token session should have been created for the new user + token_session = db_session.execute( + select(SharedUserTokenSession).where( + SharedUserTokenSession.shared_user_id == external_user.shared_user_id + ) + ).scalar_one() + assert token_session.is_valid is True + + +def test_handle_token_succeeds_existing_user( + enable_factory_create, + db_session, + login_gov_callback_handler, + mock_oauth_client, + set_login_gov_config, + private_rsa_key, +): + external_user = SharedLinkExternalUserFactory.create( + external_user_id="existing-user-xyz", email="old_email@mail.com" + ) + external_user_id = external_user.external_user_id + + code = str(uuid.uuid4()) + id_token = create_id_token( + user_id=external_user_id, + email="new_email@mail.com", + private_key=private_rsa_key, + ) + mock_oauth_client.add_token_response( + code, + OauthTokenResponse( + id_token=id_token, access_token="fake_token", token_type="Bearer", expires_in=300 + ), + ) + + response = login_gov_callback_handler.handle_token( + LoginGovDataContainer(code=code, nonce=DEFAULT_NONCE) + ) + + assert response.is_user_new is False + assert response.token is not None + + # The existing external user's email should have been updated + db_session.refresh(external_user) + assert external_user.email == "new_email@mail.com" + + +def test_handle_token_oauth_token_response_error( + enable_factory_create, + login_gov_callback_handler, + mock_oauth_client, + set_login_gov_config, +): + # No token response is registered, so the mock client returns an error response + code = str(uuid.uuid4()) + + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_token( + LoginGovDataContainer(code=code, nonce=DEFAULT_NONCE) + ) + + assert exc_info.value.status_code == 500 + assert exc_info.value.message == "default mock error description" + + # We should have exhausted all three attempts before giving up + assert mock_oauth_client.retries[code] == -3 + + +def test_handle_token_login_gov_validation_fails( + enable_factory_create, + login_gov_callback_handler, + mock_oauth_client, + set_login_gov_config, + other_rsa_key_pair, +): + # Sign the token with a key we don't validate against so validation fails + code = str(uuid.uuid4()) + id_token = create_id_token( + user_id=str(uuid.uuid4()), + private_key=other_rsa_key_pair[0], + ) + mock_oauth_client.add_token_response( + code, + OauthTokenResponse( + id_token=id_token, access_token="fake_token", token_type="Bearer", expires_in=300 + ), + ) + + with pytest.raises(HTTPError) as exc_info: + login_gov_callback_handler.handle_token( + LoginGovDataContainer(code=code, nonce=DEFAULT_NONCE) + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.message == "Invalid Signature" diff --git a/backend/grants_shared/tests/grants_shared/smoke_test.py b/backend/grants_shared/tests/grants_shared/smoke_test.py new file mode 100644 index 0000000..307ee94 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/smoke_test.py @@ -0,0 +1,37 @@ +""" +This is a smoke test we run as part of the release process. +This DOES NOT run as a unit test. + +We run it to verify that we're able to successfully import +the grants_shared code and didn't mess up the build process +before we push it to PyPi. + + +""" + + +def main(): + """ + Run the smoke test. + + We don't do imports until this function begins running + as we're testing the imports work and want to have + print output to verify where it is if it fails. + """ + print("Running smoke test.") # noqa: T201 + from datetime import datetime + + import grants_shared.util.datetime_util as datetime_util + + now = datetime_util.utcnow() + + if not isinstance(now, datetime): + raise Exception("utcnow from our datetime util was an unexpected type %s" % type(now)) + + print("Smoke test successful.") # noqa: T201 + + +# Run inside of a main function so that importing the file +# doesn't cause it to run, only if it's directly invoked. +if __name__ == "__main__": + main() diff --git a/backend/grants_shared/tests/grants_shared/task/__init__.py b/backend/grants_shared/tests/grants_shared/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/task/test_ecs_background_task.py b/backend/grants_shared/tests/grants_shared/task/test_ecs_background_task.py new file mode 100644 index 0000000..55481fb --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/task/test_ecs_background_task.py @@ -0,0 +1,155 @@ +import logging +import sys +import time + +import pytest +from flask import Flask + +from grants_shared.api.maintenance_mode import MaintenanceModeLogEvent, get_maintenance_mode_config +from grants_shared.logs.flask_logger import add_extra_data_to_global_logs, init_app +from grants_shared.task.ecs_background_task import ecs_background_task + + +@pytest.fixture(autouse=True) +def clear_maintenance_config_cache(): + # The maintenance-mode config is @cache'd, so clear it before every test to + # keep the ENABLE_MAINTENANCE_MODE env var from leaking across tests. + get_maintenance_mode_config.cache_clear() + + +@pytest.fixture +def logger(): + logger = logging.getLogger("grants_shared") + before_level = logger.level + + logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + yield logger + logger.setLevel(before_level) + logger.removeHandler(handler) + + +@pytest.fixture +def app(logger): + flask_app = Flask("test_app_name") + init_app(logger, flask_app, "test") + return flask_app + + +def test_ecs_background_task(app, caplog, monkeypatch_session): + monkeypatch_session.setenv( + "LOG_LEVEL_OVERRIDES", + "newrelic.core.agent=ERROR,newrelic.core.agent_protocol=ERROR,grants_shared.adapters.newrelic=ERROR", + ) + + # We pull in the app so its initialized + # Global logging params like the task name are stored on the app + caplog.set_level(logging.INFO) + + @ecs_background_task(task_name="my_test_task_name") + def my_test_func(param1, param2): + # Add a brief sleep so that we can test the duration logic + time.sleep(0.05) # 0.05s + add_extra_data_to_global_logs({"example_param": 12345}) + + return param1 + param2 + + # Verify the function works uneventfully + assert my_test_func(1, 2) == 3 + + # Filter out newrelic-related logs + relevant_records = [ + record for record in caplog.records if "newrelic" not in record.name.lower() + ] + for record in relevant_records: + extra = record.__dict__ + assert extra["task_name"] == "my_test_task_name" + + last_record = relevant_records[-1].__dict__ + # Make sure the ECS task duration was tracked + allowed_error = 0.03 + assert last_record["ecs_task_duration_sec"] == pytest.approx(0.05, abs=allowed_error) + # Make sure the extra we added was put in this automatically + assert last_record["example_param"] == 12345 + assert last_record["message"] == "Completed ECS task my_test_task_name" + + +def test_ecs_background_task_when_erroring(app, caplog, monkeypatch_session): + monkeypatch_session.setenv( + "LOG_LEVEL_OVERRIDES", + "newrelic.core.agent=ERROR,newrelic.core.agent_protocol=ERROR,grants_shared.adapters.newrelic=ERROR", + ) + + caplog.set_level(logging.INFO) + + @ecs_background_task(task_name="my_error_test_task_name") + def my_test_error_func(): + add_extra_data_to_global_logs({"another_param": "hello"}) + + raise ValueError("I am an error") + + with pytest.raises(ValueError, match="I am an error"): + my_test_error_func() + + # Filter out newrelic-related logs + relevant_records = [ + record for record in caplog.records if "newrelic" not in record.name.lower() + ] + for record in relevant_records: + extra = record.__dict__ + assert extra["task_name"] == "my_error_test_task_name" + + last_record = relevant_records[-1].__dict__ + + assert last_record["another_param"] == "hello" + assert last_record["levelname"] == "ERROR" + assert last_record["message"] == "ECS task failed" + assert last_record["exc_info_short"] == "ValueError('I am an error')" + + +def test_ecs_background_task_skipped_during_maintenance_mode(app, caplog, monkeypatch): + caplog.set_level(logging.INFO) + monkeypatch.setenv("ENABLE_MAINTENANCE_MODE", "true") + get_maintenance_mode_config.cache_clear() + + was_called = False + + @ecs_background_task(task_name="my_maintenance_task") + def my_test_func(): + nonlocal was_called + was_called = True + return "ran" + + # The task exits cleanly without running its wrapped (DB-touching) body. + assert my_test_func() is None + assert was_called is False + + skip_records = [ + record + for record in caplog.records + if getattr(record, "maintenance_mode_event", None) == MaintenanceModeLogEvent.TASK_SKIPPED + ] + assert len(skip_records) == 1 + assert skip_records[0].message == "Skipping ECS task due to maintenance mode" + assert skip_records[0].task_name == "my_maintenance_task" + + +def test_ecs_background_task_runs_normally_when_maintenance_mode_off(app, caplog, monkeypatch): + caplog.set_level(logging.INFO) + monkeypatch.setenv("ENABLE_MAINTENANCE_MODE", "false") + get_maintenance_mode_config.cache_clear() + + @ecs_background_task(task_name="my_non_maintenance_task") + def my_test_func(param1, param2): + return param1 + param2 + + # With maintenance off, the wrapped function runs and its return value is preserved. + assert my_test_func(2, 3) == 5 + + skip_records = [ + record + for record in caplog.records + if getattr(record, "maintenance_mode_event", None) == MaintenanceModeLogEvent.TASK_SKIPPED + ] + assert len(skip_records) == 0 diff --git a/backend/grants_shared/tests/grants_shared/test_utils/__init__.py b/backend/grants_shared/tests/grants_shared/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/grants_shared/tests/grants_shared/test_utils/assertions.py b/backend/grants_shared/tests/grants_shared/test_utils/assertions.py new file mode 100644 index 0000000..e7b17ac --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/test_utils/assertions.py @@ -0,0 +1,5 @@ +def assert_dict_contains(d: dict, expected: dict) -> None: + """Assert that d contains all the key-value pairs in expected. + Do this by checking to see if adding `expected` to `d` leaves `d` unchanged. + """ + assert d | expected == d diff --git a/backend/grants_shared/tests/grants_shared/test_utils/auth_handler.py b/backend/grants_shared/tests/grants_shared/test_utils/auth_handler.py new file mode 100644 index 0000000..cd2f9c8 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/test_utils/auth_handler.py @@ -0,0 +1,111 @@ +import uuid +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from grants_shared.auth.api_key_handler import AbstractApiKeyHandler +from grants_shared.auth.auth_handler import AbstractAuthHandler +from tests.grants_shared.db_test_models.db_test_models import ( + SharedLinkExternalUser, + SharedLoginGovState, + SharedUser, + SharedUserApiKey, + SharedUserTokenSession, +) + + +class AuthHandler( + AbstractAuthHandler[ + SharedUser, + SharedLinkExternalUser, + SharedLoginGovState, + SharedUserApiKey, + SharedUserTokenSession, + ] +): + """Concrete auth handler backed by the API's user tables.""" + + def create_token_session(self, user, token_id, expires_at): + user_token_session = SharedUserTokenSession( + shared_user=user, token_id=token_id, expires_at=expires_at + ) + self.db_session.add(user_token_session) + return user_token_session + + def get_token_session_by_token_id(self, token_id: str) -> SharedUserTokenSession | None: + return self.db_session.execute( + select(SharedUserTokenSession) + .where(SharedUserTokenSession.token_id == token_id) + .options(selectinload(SharedUserTokenSession.shared_user)) + ).scalar() + + def get_api_key_by_key_id(self, key_id: uuid.UUID) -> SharedUserApiKey | None: + return self.db_session.execute( + select(SharedUserApiKey) + .where(SharedUserApiKey.key_id == key_id) + .options(selectinload(SharedUserApiKey.shared_user)) + ).scalar_one_or_none() + + def create_api_key( + self, user_id: uuid.UUID, key_name: str, key_id: uuid.UUID + ) -> SharedUserApiKey: + api_key = SharedUserApiKey( + shared_api_key_id=uuid.uuid4(), + shared_user_id=user_id, + key_name=key_name, + key_id=key_id, + is_active=True, + ) + self.db_session.add(api_key) + return api_key + + def list_api_keys_for_user(self, user_id: uuid.UUID) -> Sequence[SharedUserApiKey]: + result = self.db_session.execute( + select(SharedUserApiKey) + .where(SharedUserApiKey.shared_user_id == user_id) + .order_by(SharedUserApiKey.created_at.desc()) + ) + return list(result.scalars().all()) + + def get_api_key_for_user( + self, user_id: uuid.UUID, api_key_id: uuid.UUID + ) -> SharedUserApiKey | None: + return self.db_session.execute( + select(SharedUserApiKey).filter( + SharedUserApiKey.shared_api_key_id == api_key_id, + SharedUserApiKey.shared_user_id == user_id, + ) + ).scalar_one_or_none() + + def create_login_gov_state(self, state_id, nonce): ... + + def get_login_gov_state(self, state_id): + return self.db_session.execute( + select(SharedLoginGovState).where( + SharedLoginGovState.shared_login_gov_state_id == state_id + ) + ).scalar_one_or_none() + + def get_link_external_user(self, external_user_id): + return self.db_session.execute( + select(SharedLinkExternalUser).where( + SharedLinkExternalUser.external_user_id == external_user_id + ) + ).scalar_one_or_none() + + def create_user_with_external_link(self, external_user_id: str): + user = SharedUser() + external_user = SharedLinkExternalUser(shared_user=user, external_user_id=external_user_id) + self.db_session.add(user) + self.db_session.add(external_user) + return external_user + + def get_user_for_external_link(self, external_user): + return external_user.shared_user + + +class SharedApiKeyHandler(AbstractApiKeyHandler[SharedUserApiKey]): + + def get_auth_handler(self) -> AuthHandler: + return AuthHandler(self.db_session) diff --git a/backend/grants_shared/tests/grants_shared/test_utils/db_testing.py b/backend/grants_shared/tests/grants_shared/test_utils/db_testing.py new file mode 100644 index 0000000..dd827ca --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/test_utils/db_testing.py @@ -0,0 +1,60 @@ +"""Helper functions for testing database code.""" + +import contextlib +import logging + +from sqlalchemy import text + +import grants_shared.adapters.db as db +from grants_shared.adapters.db.clients.postgres_config import get_db_config + +logger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def create_isolated_db(monkeypatch, db_schema_prefix) -> db.DBClient: + """ + Creates a temporary PostgreSQL schema and creates a database engine + that connects to that schema. Drops the schema after the context manager + exits. + """ + + # To improve test performance, don't check the database connection + # when initializing the DB client. + monkeypatch.setenv("DB_CHECK_CONNECTION_ON_INIT", "False") + # We set the prefix override here so when the API client creates a DB config + # it also has the appropriate prefix value for mapping + monkeypatch.setenv("SCHEMA_PREFIX_OVERRIDE", db_schema_prefix) + + db_config = db.PostgresDBConfig(schema_prefix_override=db_schema_prefix) + db_client = db.PostgresDBClient(db_config) + test_schemas = db_config.get_schema_translate_map().values() + + with db_client.get_connection() as conn: + for schema in test_schemas: + _create_schema(conn, schema) + + try: + yield db_client + + finally: + for schema in test_schemas: + _drop_schema(conn, schema) + + +def _create_schema(conn: db.Connection, schema_name: str): + """Create a database schema.""" + db_test_user = get_db_config().username + + with conn.begin(): + conn.execute( + text(f"CREATE SCHEMA IF NOT EXISTS {schema_name} AUTHORIZATION {db_test_user};") + ) + logger.info("create schema %s", schema_name) + + +def _drop_schema(conn: db.Connection, schema_name: str): + """Drop a database schema.""" + with conn.begin(): + conn.execute(text(f"DROP SCHEMA {schema_name} CASCADE;")) + logger.info("drop schema %s", schema_name) diff --git a/backend/grants_shared/tests/grants_shared/test_utils/login_gov_callback_handler.py b/backend/grants_shared/tests/grants_shared/test_utils/login_gov_callback_handler.py new file mode 100644 index 0000000..50a9d37 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/test_utils/login_gov_callback_handler.py @@ -0,0 +1,8 @@ +from grants_shared.services.users.login_gov_callback_handler import AbstractLoginGovCallbackHandler + + +class LoginGovCallbackHandler(AbstractLoginGovCallbackHandler): + """Applicant-side login.gov callback handler.""" + + def handle_post_login(self, user, is_user_new, login_gov_user): + """Nothing needed for post login for testing""" diff --git a/backend/grants_shared/tests/grants_shared/util/test_api_key_gen.py b/backend/grants_shared/tests/grants_shared/util/test_api_key_gen.py new file mode 100644 index 0000000..c08b3d7 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_api_key_gen.py @@ -0,0 +1,88 @@ +import string +from unittest.mock import patch + +from grants_shared.util.api_key_gen import generate_api_key_id + + +class TestGenerateApiKeyId: + def test_generate_api_key_id_default_length(self): + """Test generate_api_key_id returns 25 character string by default.""" + key_id = generate_api_key_id() + assert len(key_id) == 25 + + def test_generate_api_key_id_custom_length(self): + """Test generate_api_key_id with custom length.""" + key_id = generate_api_key_id(length=10) + assert len(key_id) == 10 + + def test_generate_api_key_id_contains_valid_characters(self): + """Test generate_api_key_id only contains alphanumeric characters.""" + key_id = generate_api_key_id() + allowed_chars = string.ascii_letters + string.digits + assert all(c in allowed_chars for c in key_id) + + def test_generate_api_key_id_contains_mixed_case_and_numbers(self): + """Test generate_api_key_id can contain uppercase, lowercase, and numbers.""" + key_ids = [generate_api_key_id(length=100) for _ in range(10)] + combined = "".join(key_ids) + + has_uppercase = any(c.isupper() for c in combined) + has_lowercase = any(c.islower() for c in combined) + has_digit = any(c.isdigit() for c in combined) + + assert has_uppercase, "Should contain uppercase letters" + assert has_lowercase, "Should contain lowercase letters" + assert has_digit, "Should contain digits" + + def test_generate_api_key_id_is_random(self): + """Test generate_api_key_id generates different values on each call.""" + key_id1 = generate_api_key_id() + key_id2 = generate_api_key_id() + assert key_id1 != key_id2 + + def test_generate_api_key_id_zero_length(self): + """Test generate_api_key_id with zero length returns empty string.""" + key_id = generate_api_key_id(length=0) + assert key_id == "" + + def test_generate_api_key_id_large_length(self): + """Test generate_api_key_id with large length.""" + key_id = generate_api_key_id(length=1000) + assert len(key_id) == 1000 + + @patch("grants_shared.util.api_key_gen.secrets.choice") + def test_generate_api_key_id_uses_secrets(self, mock_choice): + """Test generate_api_key_id uses secrets module for cryptographic randomness.""" + mock_choice.return_value = "A" + + key_id = generate_api_key_id(length=5) + + assert key_id == "AAAAA" + assert mock_choice.call_count == 5 + + def test_generate_api_key_id_character_distribution(self): + """Test that generated keys contain all character types with reasonable distribution.""" + sample_size = 1000 + key_id = generate_api_key_id(length=sample_size) + + uppercase_count = sum(1 for c in key_id if c.isupper()) + lowercase_count = sum(1 for c in key_id if c.islower()) + digit_count = sum(1 for c in key_id if c.isdigit()) + + assert uppercase_count > 0, "Should contain uppercase letters" + assert lowercase_count > 0, "Should contain lowercase letters" + assert digit_count > 0, "Should contain digits" + + min_expected = sample_size * 0.1 + assert uppercase_count >= min_expected, f"Uppercase count {uppercase_count} too low" + assert lowercase_count >= min_expected, f"Lowercase count {lowercase_count} too low" + assert digit_count >= min_expected, f"Digit count {digit_count} too low" + + def test_generate_api_key_id_no_special_characters(self): + """Test that generated keys contain no special characters.""" + key_id = generate_api_key_id(length=100) + + special_chars = "!@#$%^&*()_+-=[]{}|;':\",./<>?" + assert not any(c in special_chars for c in key_id) + + assert " " not in key_id diff --git a/backend/grants_shared/tests/grants_shared/util/test_datetime_util.py b/backend/grants_shared/tests/grants_shared/util/test_datetime_util.py new file mode 100644 index 0000000..579292b --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_datetime_util.py @@ -0,0 +1,139 @@ +from datetime import date, datetime, timezone + +import pytest +import pytz + +from grants_shared.util.datetime_util import adjust_timezone, from_timestamp, parse_grants_gov_date + + +@pytest.mark.parametrize( + "timezone_name, expected_output", + [ + ("UTC", "2022-01-01T12:00:00+00:00"), + ("US/Eastern", "2022-01-01T07:00:00-05:00"), + ("US/Central", "2022-01-01T06:00:00-06:00"), + ("US/Mountain", "2022-01-01T05:00:00-07:00"), + ("US/Pacific", "2022-01-01T04:00:00-08:00"), + ("Asia/Tokyo", "2022-01-01T21:00:00+09:00"), + ], +) +def test_adjust_timezone_from_utc(timezone_name, expected_output): + # Jan 1st 2022 at 12:00pm is the input + input_datetime = datetime(2022, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + # Note that we use the isoformat for validation as a timezone shifted + # timezone matches the unshifted one (eg. 12pm UTC and 7am Eastern match) + # so comparing the timezone objects themselves is more complicated than + # looking at their string representation. + + # Passing in UTC doesn't change the time + assert adjust_timezone(input_datetime, timezone_name).isoformat() == expected_output + + +@pytest.mark.parametrize( + "timezone_name, expected_output", + [ + ("UTC", "2022-06-01T05:00:00+00:00"), + ("US/Eastern", "2022-06-01T01:00:00-04:00"), + ("US/Central", "2022-06-01T00:00:00-05:00"), + ("US/Mountain", "2022-05-31T23:00:00-06:00"), + ("US/Pacific", "2022-05-31T22:00:00-07:00"), + ("Asia/Tokyo", "2022-06-01T14:00:00+09:00"), + ], +) +def test_adjust_timezone_from_non_utc(timezone_name, expected_output): + # June 1st 2022 at 01:00am in the Eastern timezone is the input + input_datetime = pytz.timezone("America/New_York").localize(datetime(2022, 6, 1, 1, 0, 0)) + + # Note that because daylights savings has switched from the above + # test, the differences in the timezones is offset by an hour + # in a few places that don't observe DST (the US timezones are all 1 hour closer to UTC) + + assert adjust_timezone(input_datetime, timezone_name).isoformat() == expected_output + + +class TestParseGrantsGovDate: + """Test the parse_grants_gov_date function""" + + def test_parse_date_with_negative_timezone_suffix(self): + """Test parsing date with negative timezone suffix like grants.gov returns""" + result = parse_grants_gov_date("2025-09-16-04:00") + expected = date(2025, 9, 16) + assert result == expected + + def test_parse_date_with_positive_timezone_suffix(self): + """Test parsing date with positive timezone suffix""" + result = parse_grants_gov_date("2025-09-16+05:00") + expected = date(2025, 9, 16) + assert result == expected + + def test_parse_standard_iso_date(self): + """Test parsing standard ISO date format without timezone""" + result = parse_grants_gov_date("2025-09-16") + expected = date(2025, 9, 16) + assert result == expected + + def test_parse_none_returns_none(self): + """Test that None input returns None""" + result = parse_grants_gov_date(None) + assert result is None + + def test_parse_empty_string_returns_none(self): + """Test that empty string returns None""" + result = parse_grants_gov_date("") + assert result is None + + def test_parse_invalid_date_raises_error(self): + """Test that invalid date format raises ValueError""" + with pytest.raises(ValueError, match="Could not parse date string"): + parse_grants_gov_date("not-a-date") + + def test_parse_invalid_format_raises_error(self): + """Test that malformed date raises ValueError""" + with pytest.raises(ValueError, match="Could not parse date string"): + parse_grants_gov_date("2025-13-45") # Invalid month and day + + def test_parse_date_with_different_timezone_offsets(self): + """Test parsing dates with various timezone offset formats""" + test_cases = [ + "2025-09-16-04:00", # UTC-4 + "2025-09-16+05:30", # UTC+5:30 (India) + "2025-09-16-11:00", # UTC-11 + "2025-09-16+14:00", # UTC+14 (Line Islands) + ] + + expected = date(2025, 9, 16) + + for test_case in test_cases: + result = parse_grants_gov_date(test_case) + assert result == expected, f"Failed for input: {test_case}" + + def test_parse_leap_year_date(self): + """Test parsing a leap year date""" + result = parse_grants_gov_date("2024-02-29-05:00") + expected = date(2024, 2, 29) + assert result == expected + + def test_parse_edge_case_dates(self): + """Test parsing edge case dates like year boundaries""" + test_cases = [ + ("2024-01-01+00:00", date(2024, 1, 1)), + ("2024-12-31-12:00", date(2024, 12, 31)), + ("2000-02-29+06:00", date(2000, 2, 29)), # Leap year + ] + + for date_str, expected in test_cases: + result = parse_grants_gov_date(date_str) + assert result == expected, f"Failed for input: {date_str}" + + +@pytest.mark.parametrize( + "value, expected", + [ + (1773762290934, datetime(2026, 3, 17, 15, 44, 50, 934000, tzinfo=timezone.utc)), + (1234567890000, datetime(2009, 2, 13, 23, 31, 30, 0, tzinfo=timezone.utc)), + (2222222222222, datetime(2040, 6, 2, 3, 57, 2, 222000, tzinfo=timezone.utc)), + ], +) +def test_from_timestamp(value, expected): + assert from_timestamp(value) == expected diff --git a/backend/grants_shared/tests/grants_shared/util/test_decimal_util.py b/backend/grants_shared/tests/grants_shared/util/test_decimal_util.py new file mode 100644 index 0000000..e12e61c --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_decimal_util.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +import pytest + +from grants_shared.util.decimal_util import ZERO_DECIMAL, convert_monetary_field, quantize_decimal + + +def test_convert_monetary_field(): + assert convert_monetary_field(None) == ZERO_DECIMAL + assert convert_monetary_field("1") == Decimal("1") + assert convert_monetary_field("1.0005") == Decimal("1.0005") + + +@pytest.mark.parametrize("value", [10, "hello", {}, "1.2.3.4.5"]) +def test_convert_monetary_field_error_cases(value): + with pytest.raises(ValueError): + convert_monetary_field(value) + + +def test_quantize_decimal(): + assert quantize_decimal(Decimal("1.00")) == Decimal("1.00") + assert quantize_decimal(Decimal("1.456")) == Decimal("1.46") + assert quantize_decimal(Decimal("1.351")) == Decimal("1.35") + assert quantize_decimal(Decimal("-.56")) == Decimal("-.56") + assert quantize_decimal(Decimal("-1000.000001")) == Decimal("-1000.00") + assert quantize_decimal(Decimal("100")) == Decimal("100") + assert quantize_decimal(Decimal("20.1")) == Decimal("20.1") diff --git a/backend/grants_shared/tests/grants_shared/util/test_deploy_metadata.py b/backend/grants_shared/tests/grants_shared/util/test_deploy_metadata.py new file mode 100644 index 0000000..c113b91 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_deploy_metadata.py @@ -0,0 +1,48 @@ +from freezegun import freeze_time + +from grants_shared.util.deploy_metadata import DeployMetadataConfig + + +def test_deploy_metadata_with_release_ref(monkeypatch): + ref = "2024.11.27-1" + sha = "44b954e85c4ca7e3714f9f988a919fae40ec3c98" + + monkeypatch.setenv("DEPLOY_GITHUB_REF", ref) + monkeypatch.setenv("DEPLOY_GITHUB_SHA", sha) + monkeypatch.setenv("DEPLOY_TIMESTAMP", "2024-12-02T21:25:18Z") + + config = DeployMetadataConfig() + + # Verify the calculated values are there + assert config.release_notes == f"https://github.com/HHS/simpler-grants-gov/releases/tag/{ref}" + assert config.deploy_commit == f"https://github.com/HHS/simpler-grants-gov/commit/{sha}" + assert config.deploy_datetime_est.isoformat() == "2024-12-02T16:25:18-05:00" + + +def test_deploy_metadata_with_non_release_ref(monkeypatch): + sha = "44b954e85c4ca7e3714f9f988a919fae40ec3c98" + + monkeypatch.setenv("DEPLOY_GITHUB_REF", "main") + monkeypatch.setenv("DEPLOY_GITHUB_SHA", sha) + monkeypatch.setenv("DEPLOY_TIMESTAMP", "2024-06-01T03:13:11Z") + + config = DeployMetadataConfig() + + # Verify the calculated values are there + assert config.release_notes == "https://github.com/HHS/simpler-grants-gov/releases" + assert config.deploy_commit == f"https://github.com/HHS/simpler-grants-gov/commit/{sha}" + assert config.deploy_datetime_est.isoformat() == "2024-05-31T23:13:11-04:00" + + +@freeze_time("2024-11-14 12:00:00", tz_offset=0) +def test_deploy_metadata_all_none(monkeypatch): + monkeypatch.delenv("DEPLOY_GITHUB_REF", raising=False) + monkeypatch.delenv("DEPLOY_GITHUB_SHA", raising=False) + monkeypatch.delenv("DEPLOY_TIMESTAMP", raising=False) + + config = DeployMetadataConfig() + + # Verify the calculated values are there + assert config.release_notes == "https://github.com/HHS/simpler-grants-gov/releases" + assert config.deploy_commit == "https://github.com/HHS/simpler-grants-gov" + assert config.deploy_datetime_est.isoformat() == "2024-11-14T07:00:00-05:00" diff --git a/backend/grants_shared/tests/grants_shared/util/test_dict_util.py b/backend/grants_shared/tests/grants_shared/util/test_dict_util.py new file mode 100644 index 0000000..03433de --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_dict_util.py @@ -0,0 +1,192 @@ +import pytest + +from grants_shared.util.dict_util import diff_nested_dicts, flatten_dict, get_nested_value + + +@pytest.mark.parametrize( + "data,expected_output", + [ + # Scenario 1 - routine case + ( + {"a": {"b": {"c": "value_c", "f": 5}, "d": "value_d"}, "e": "value_e"}, + {"a.b.c": "value_c", "a.b.f": 5, "a.d": "value_d", "e": "value_e"}, + ), + # Scenario 2 - empty + ({}, {}), + # Scenario 3 - no nesting + ( + { + "a": "1", + "b": 2, + "c": True, + }, + { + "a": "1", + "b": 2, + "c": True, + }, + ), + # Scenario 4 - very nested + ( + { + "a": { + "b": { + "c": { + "d": { + "e": { + "f": {"g": {"h1": "h1_value", "h2": ["h2_value1", "h2_value2"]}} + } + } + } + } + } + }, + {"a.b.c.d.e.f.g.h1": "h1_value", "a.b.c.d.e.f.g.h2": ["h2_value1", "h2_value2"]}, + ), + # Scenario 5 - dictionaries inside non-dictionaries aren't flattened + ({"a": {"b": [{"list_dict_a": "a"}]}}, {"a.b": [{"list_dict_a": "a"}]}), + # Scenario 6 - integer keys should be allowed too + ({"a": {0: {"b": "b_value"}, 1: "c"}}, {"a.0.b": "b_value", "a.1": "c"}), + ], +) +def test_flatten_dict(data, expected_output): + assert flatten_dict(data) == expected_output + + +@pytest.mark.parametrize( + "dict1,dict2,expected_output", + [ + ( + # dict1 + {"a": "apple", "b": {"x": 1, "y": 2}, "c": 100}, # additional field a + # dict2 + { + "b": {"x": 1, "y": 3}, # changed y + "c": 200, # changed c + "d": "dog", # new field added + }, + # expected output + [ + {"field": "a", "before": "apple", "after": None}, + {"field": "b.y", "before": 2, "after": 3}, + {"field": "c", "before": 100, "after": 200}, + {"field": "d", "before": None, "after": "dog"}, + ], + ), + ( + # dict1 + {"a": "ball", "b": {"p": 5, "q": 10}, "e": "elephant"}, + # dict2 + { + "a": "bat", # changed a + "b": {"p": 5, "q": 1.1}, # no change # changed q + "e": "elephant", # no change + }, + # expected output + [ + {"field": "a", "before": "ball", "after": "bat"}, + {"field": "b.q", "before": 10, "after": 1.1}, + ], + ), + ( + # dict1 + {"x": 10, "y": "yellow", "z": {"m": "mouse", "n": True}}, + # dict2 + { + "x": 10, # no change + "y": "yellow", # no change + "z": {"m": "mouse", "n": False}, # no change # changed n + }, + # expected output + [{"field": "z.n", "before": True, "after": False}], + ), + ( + # dict1 + {"x": {"x": {"x": [1, 2, True]}}}, + # dict2 + {"x": {"x": {"x": [1, 2, True]}}}, # no change + # expected output + [], + ), + ( + # dict1 + {"x": {"x": {"x": [1, 2, True]}}}, + # dict2 + {"x": {"x": {"x": [1, True, 2]}}}, # re-ordered list + # expected output + [], + ), + ( + # dict1 + {"x": {"x": [1, 2], "z": None}}, # missing y + # dict2 + {"x": {"y": [1, 2], "z": 4}}, # missing x + # expected output + [ + {"field": "x.x", "before": [1, 2], "after": None}, + {"field": "x.y", "before": None, "after": [1, 2]}, + {"field": "x.z", "before": None, "after": 4}, + ], + ), + ], +) +def test_diff_nested_dicts(dict1, dict2, expected_output): + result = diff_nested_dicts(dict1, dict2) + + assert len(result) == len(expected_output) + + expected_sorted = sorted(expected_output, key=lambda x: x["field"]) + sorted_result = sorted(result, key=lambda x: x["field"]) + + assert expected_sorted == sorted_result + + +# Test data for get_nested_value tests +COMPLEX_ARRAY_DATA = { + "array_field": [ + {"x": 1, "y": "hello", "nested_array": [{"a": 10, "b": 4}, {"a": 15}]}, + {"x": 3, "y": "there", "z": "words", "nested_array": [{"a": 5, "b": 6}]}, + {"nested_array": [{"g": 100}]}, + {"e": "text"}, + ] +} + + +@pytest.mark.parametrize( + "json_data,path,expected_value", + [ + ({"my_field": 5}, ["my_field"], 5), + ({"nested": {"path": {"to": {"value": 10}}}}, ["nested", "path", "to", "value"], 10), + # Path doesn't fully exist + ({}, ["whatever", "path"], None), + ({"whatever": {}}, ["whatever", "path"], None), + # Can fetch a whole chunk + ( + {"nested": {"path": {"to": {"value": "hello"}}}}, + ["nested"], + {"path": {"to": {"value": "hello"}}}, + ), + ( + {"nested": {"path": ["hello", "there", "this is a text"]}}, + ["nested", "path"], + ["hello", "there", "this is a text"], + ), + # Passing in an empty path returns itself + ({"example": 5, "nested": {"field": 100}}, [], {"example": 5, "nested": {"field": 100}}), + ], +) +def test_get_nested_value(json_data, path, expected_value): + assert get_nested_value(json_data, path) == expected_value + + +@pytest.mark.parametrize( + "path,expected_value", + [ + (["array_field[0]", "x"], 1), + (["array_field[*]", "x"], [1, 3]), + (["array_field[*]", "nested_array[*]", "a"], [10, 15, 5]), + (["array_field[*]", "nested_array[*]", "b"], [4, 6]), + ], +) +def test_get_nested_value_of_arrays(path, expected_value): + assert get_nested_value(COMPLEX_ARRAY_DATA, path) == expected_value diff --git a/backend/grants_shared/tests/grants_shared/util/test_file_util.py b/backend/grants_shared/tests/grants_shared/util/test_file_util.py new file mode 100644 index 0000000..86e67f7 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_file_util.py @@ -0,0 +1,307 @@ +import os +import uuid +from urllib.parse import parse_qs, urlparse + +import boto3 +import faker +import pytest +from smart_open import open as smart_open + +import grants_shared.util.file_util as file_util +from grants_shared.adapters.aws import S3Config + +fake = faker.Faker() + + +def create_file(root_path, file_path): + full_path = os.path.join(root_path, file_path) + + if not file_util.is_s3_path(str(full_path)): + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + with smart_open(full_path, mode="w") as outfile: + outfile.write("hello") + + return full_path + + +@pytest.mark.parametrize( + "path,is_s3", + [ + ("s3://bucket/folder/test.txt", True), + ("./relative/folder/test.txt", False), + ("http://example.com/test.txt", False), + ], +) +def test_is_s3_path(path, is_s3): + assert file_util.is_s3_path(path) is is_s3 + + +@pytest.mark.parametrize( + "path,bucket,prefix", + [ + ("s3://my_bucket/my_key", "my_bucket", "my_key"), + ("s3://my_bucket/path/to/directory/", "my_bucket", "path/to/directory/"), + ("s3://my_bucket/path/to/file.txt", "my_bucket", "path/to/file.txt"), + ], +) +def test_split_s3_url(path, bucket, prefix): + assert file_util.split_s3_url(path) == (bucket, prefix) + + +@pytest.mark.parametrize( + "path,bucket", + [ + ("s3://bucket/folder/test.txt", "bucket"), + ("s3://bucket_x/folder", "bucket_x"), + ("s3://bucket-y/folder/", "bucket-y"), + ("s3://bucketz", "bucketz"), + ], +) +def test_get_s3_bucket(path, bucket): + assert file_util.get_s3_bucket(path) == bucket + + +@pytest.mark.parametrize( + "path,file_key", + [ + ("s3://bucket/folder/test.txt", "folder/test.txt"), + ("s3://bucket_x/file.csv", "file.csv"), + ("s3://bucket-y/folder/path/to/abc.zip", "folder/path/to/abc.zip"), + ("./folder/path", "./folder/path"), + ("sftp://folder/filename", "filename"), + ], +) +def test_get_s3_file_key(path, file_key): + assert file_util.get_s3_file_key(path) == file_key + + +@pytest.mark.parametrize( + "path,file_name", + [ + ("s3://bucket/folder/test.txt", "test.txt"), + ("s3://bucket_x/file.csv", "file.csv"), + ("s3://bucket-y/folder/path/to/abc.zip", "abc.zip"), + ("./folder/path", "path"), + ("sftp://filename", "filename"), + ], +) +def test_get_s3_file_name(path, file_name): + assert file_util.get_file_name(path) == file_name + + +@pytest.mark.parametrize( + "path,file_name", + [ + ("s3://bucket/folder/test~test.txt", "testtest.txt"), + ("s3://bucket_x/file.csv", "file.csv"), + ("s3://bucket-y/folder/path/to/abc has spaces.zip", "abc_has_spaces.zip"), + ("./folder/path file\\x", "path_filex"), + ("sftp://../../..//filename.....", "filename"), + ], +) +def test_get_secure_file_name(path, file_name): + assert file_util.get_secure_file_name(path) == file_name + + +def test_get_file_length_bytes(tmp_path): + test_content = "Hello, World!" + test_file = tmp_path / "test.txt" + test_file.write_text(test_content) + + size = file_util.get_file_length_bytes(str(test_file)) + + # Verify size matches content length + assert size == len(test_content) + + +def test_get_file_length_bytes_s3_with_content(mock_s3_bucket): + """Test getting file size from S3 with actual content""" + # Create test content + test_content = b"Test content!" + test_file_path = f"s3://{mock_s3_bucket}/test/file.txt" + + # Upload test content to mock S3 + s3_client = boto3.client("s3") + s3_client.put_object(Bucket=mock_s3_bucket, Key="test/file.txt", Body=test_content) + + # Get file size using our utility + size = file_util.get_file_length_bytes(test_file_path) + + # Verify size matches content length + assert size == len(test_content) + + +def test_file_exists_local_filesystem(tmp_path): + file_path1 = tmp_path / "test.txt" + file_path2 = tmp_path / "test2.txt" + file_path3 = tmp_path / "test3.txt" + + with file_util.open_stream(file_path1, "w") as outfile: + outfile.write("hello") + with file_util.open_stream(file_path2, "w") as outfile: + outfile.write("hello") + with file_util.open_stream(file_path3, "w") as outfile: + outfile.write("hello") + + assert file_util.file_exists(file_path1) is True + assert file_util.file_exists(file_path2) is True + assert file_util.file_exists(file_path3) is True + assert file_util.file_exists(tmp_path / "test4.txt") is False + assert file_util.file_exists(tmp_path / "test5.txt") is False + + +def test_file_exists_s3(mock_s3_bucket): + file_path1 = f"s3://{mock_s3_bucket}/test.txt" + file_path2 = f"s3://{mock_s3_bucket}/test2.txt" + file_path3 = f"s3://{mock_s3_bucket}/test3.txt" + + with file_util.open_stream(file_path1, "w") as outfile: + outfile.write("hello") + with file_util.open_stream(file_path2, "w") as outfile: + outfile.write("hello") + with file_util.open_stream(file_path3, "w") as outfile: + outfile.write("hello") + + assert file_util.file_exists(file_path1) is True + assert file_util.file_exists(file_path2) is True + assert file_util.file_exists(file_path3) is True + assert file_util.file_exists(f"s3://{mock_s3_bucket}/test4.txt") is False + assert file_util.file_exists(f"s3://{mock_s3_bucket}/test5.txt") is False + + +def test_copy_file_s3(mock_s3_bucket, other_mock_s3_bucket): + file_path = f"s3://{mock_s3_bucket}/my_file.txt" + + with file_util.open_stream(file_path, "w") as outfile: + outfile.write(fake.sentence(25)) + + other_file_path = f"s3://{other_mock_s3_bucket}/my_new_file.txt" + file_util.copy_file(file_path, other_file_path) + + assert file_util.file_exists(file_path) is True + assert file_util.file_exists(other_file_path) is True + + assert file_util.read_file(file_path) == file_util.read_file(other_file_path) + + +def test_copy_file_local_disk(tmp_path): + file_path = tmp_path / "my_file.txt" + + with file_util.open_stream(file_path, "w") as outfile: + outfile.write(fake.sentence(25)) + + other_file_path = tmp_path / "my_file2.txt" + file_util.copy_file(file_path, other_file_path) + + assert file_util.file_exists(file_path) is True + assert file_util.file_exists(other_file_path) is True + + assert file_util.read_file(file_path) == file_util.read_file(other_file_path) + + +def test_move_file_s3(mock_s3_bucket, other_mock_s3_bucket): + file_path = f"s3://{mock_s3_bucket}/my_file_to_copy.txt" + + contents = fake.sentence(25) + with file_util.open_stream(file_path, "w") as outfile: + outfile.write(contents) + + other_file_path = f"s3://{other_mock_s3_bucket}/my_destination_file.txt" + file_util.move_file(file_path, other_file_path) + + assert file_util.file_exists(file_path) is False + assert file_util.file_exists(other_file_path) is True + + assert file_util.read_file(other_file_path) == contents + + +def test_move_file_local_disk(tmp_path): + file_path = tmp_path / "my_file_to_move.txt" + + contents = fake.sentence(25) + with file_util.open_stream(file_path, "w") as outfile: + outfile.write(contents) + + other_file_path = tmp_path / "my_moved_file.txt" + file_util.move_file(file_path, other_file_path) + + assert file_util.file_exists(file_path) is False + assert file_util.file_exists(other_file_path) is True + + assert file_util.read_file(other_file_path) == contents + + +@pytest.mark.parametrize( + "s3_path,cdn_url,expected", + [ + ( + "s3://local-mock-public-bucket/path/to/file.pdf", + "https://cdn.example.com", + "https://cdn.example.com/path/to/file.pdf", + ), + ( + "s3://local-mock-public-bucket/opportunities/9/attachments/79853231/manager.webm", + "https://cdn.example.com", + "https://cdn.example.com/opportunities/9/attachments/79853231/manager.webm", + ), + # Test with subdirectory in CDN URL + ( + "s3://local-mock-public-bucket/file.txt", + "https://cdn.example.com/assets", + "https://cdn.example.com/assets/file.txt", + ), + ], +) +def test_convert_s3_to_cdn_url(s3_path, cdn_url, expected, s3_config): + assert file_util.convert_public_s3_to_cdn_url(s3_path, cdn_url, s3_config) == expected + + +def test_convert_s3_to_cdn_url_invalid_path(s3_config): + with pytest.raises(ValueError, match="Expected s3:// path"): + file_util.convert_public_s3_to_cdn_url( + "http://not-s3/file.txt", "cdn.example.com", s3_config + ) + + +def test_write_to_file(tmp_path): + contents = fake.sentence(25) + file_path = tmp_path / "my_file_to_write.txt" + assert file_util.file_exists(file_path) is False + file_util.write_to_file(file_path, contents) + assert file_util.file_exists(file_path) is True + assert file_util.read_file(file_path) == contents + + +def test_pre_sign_file_location_uses_configured_duration(mock_s3_bucket): + """Presigned URLs use the duration from S3Config (defaults to 15 minutes).""" + s3_config = S3Config( + PUBLIC_FILES_BUCKET=f"s3://{mock_s3_bucket}", + DRAFT_FILES_BUCKET=f"s3://{mock_s3_bucket}", + ) + + url = file_util.pre_sign_file_location( + f"s3://{mock_s3_bucket}/some/file.txt", s3_config=s3_config + ) + + query = parse_qs(urlparse(url).query) + assert int(query["X-Amz-Expires"][0]) == 900 + + +def test_presigned_post_local_override_with_s3_endpoint_url(mock_s3_bucket, s3_config): + file_id = uuid.uuid4() + user_id = uuid.uuid4() + + s3_config.aws_s3_endpoint_url = "http://mocks3:9090" + + result = file_util.pre_sign_upload( + file_path=f"s3://{mock_s3_bucket}/some/file.txt", + content_type="text/plain", + metadata={ + "file-id": str(file_id), + "user-id": str(user_id), + }, + s3_config=s3_config, + ) + + assert result["url"].startswith("http://localhost:9090") diff --git a/backend/grants_shared/tests/grants_shared/util/test_json_util.py b/backend/grants_shared/tests/grants_shared/util/test_json_util.py new file mode 100644 index 0000000..d6a3efa --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_json_util.py @@ -0,0 +1,56 @@ +import json +import uuid +from datetime import date, datetime +from decimal import Decimal +from enum import StrEnum + +import pytest +import pytz + +import grants_shared.util.json_util as json_util + + +class EnumField(StrEnum): + MY_VALUE = "my_value" + + +@pytest.mark.parametrize( + "value,expected", + [ + # string + ("hello", '{"field":"hello"}'), + # int + (5, '{"field":5}'), + # float + (1.5, '{"field":1.5}'), + # bool + (True, '{"field":true}'), + # list + ([1, 2, 3], '{"field":[1,2,3]}'), + # datetime + ( + datetime(2026, 5, 12, 12, 15, 25, tzinfo=pytz.utc), + '{"field":"2026-05-12T12:15:25+00:00"}', + ), + # date + (date(2026, 5, 12), '{"field":"2026-05-12"}'), + # enum + (EnumField.MY_VALUE, '{"field":"my_value"}'), + # set + ({1, 2, 3}, '{"field":[1,2,3]}'), + # decimal + (Decimal("1.234"), '{"field":"1.234"}'), + # uuid + ( + uuid.UUID("14975001-b561-4e5d-aad7-b60776984807"), + '{"field":"14975001-b561-4e5d-aad7-b60776984807"}', + ), + # exception + (ValueError("an error"), '{"field":"an error"}'), + ], +) +def test_json_encoder(value, expected): + """Test that the json encoder we have defined works to convert types""" + raw_data = {"field": value} + result = json.dumps(raw_data, separators=(",", ":"), default=json_util.json_encoder) + assert result == expected diff --git a/backend/grants_shared/tests/grants_shared/util/test_local.py b/backend/grants_shared/tests/grants_shared/util/test_local.py new file mode 100644 index 0000000..b0f65f3 --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_local.py @@ -0,0 +1,77 @@ +import os + +import pytest + +from grants_shared.util.local import error_if_not_local, load_local_env_vars + + +class TestLoadLocalEnvVars: + def test_loads_dotenv_when_environment_is_local(self, monkeypatch, tmp_path): + env_file = tmp_path / "local.env" + env_file.write_text("TEST_VAR=hello\n") + monkeypatch.setenv("ENVIRONMENT", "local") + monkeypatch.delenv("TEST_VAR", raising=False) + + load_local_env_vars(str(env_file)) + + assert os.getenv("TEST_VAR") == "hello" + + def test_loads_dotenv_when_environment_is_unset(self, monkeypatch, tmp_path): + env_file = tmp_path / "local.env" + env_file.write_text("TEST_VAR2=world\n") + monkeypatch.delenv("ENVIRONMENT", raising=False) + monkeypatch.delenv("TEST_VAR2", raising=False) + + load_local_env_vars(str(env_file)) + + assert os.getenv("TEST_VAR2") == "world" + + def test_skips_dotenv_when_environment_is_not_local(self, monkeypatch, tmp_path): + env_file = tmp_path / "local.env" + env_file.write_text("TEST_VAR3=should_not_load\n") + monkeypatch.setenv("ENVIRONMENT", "dev") + monkeypatch.delenv("TEST_VAR3", raising=False) + + load_local_env_vars(str(env_file)) + + assert os.getenv("TEST_VAR3") is None + + @pytest.mark.parametrize("env", ["staging", "prod", "production", "test"]) + def test_skips_dotenv_for_non_local_environments(self, monkeypatch, tmp_path, env): + env_file = tmp_path / "local.env" + env_file.write_text("SKIP_VAR=skip\n") + monkeypatch.setenv("ENVIRONMENT", env) + monkeypatch.delenv("SKIP_VAR", raising=False) + + load_local_env_vars(str(env_file)) + + assert os.getenv("SKIP_VAR") is None + + +class TestErrorIfNotLocal: + def test_passes_when_environment_is_local(self, monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "local") + # Should not raise + error_if_not_local() + + def test_raises_when_environment_is_not_local(self, monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "dev") + with pytest.raises( + Exception, match="Local-only process called when environment was set to non-local" + ): + error_if_not_local() + + def test_raises_when_environment_is_unset(self, monkeypatch): + monkeypatch.delenv("ENVIRONMENT", raising=False) + with pytest.raises( + Exception, match="Local-only process called when environment was set to non-local" + ): + error_if_not_local() + + @pytest.mark.parametrize("env", ["staging", "prod", "production", "test"]) + def test_raises_for_non_local_environments(self, monkeypatch, env): + monkeypatch.setenv("ENVIRONMENT", env) + with pytest.raises( + Exception, match="Local-only process called when environment was set to non-local" + ): + error_if_not_local() diff --git a/backend/grants_shared/tests/grants_shared/util/test_string_utils.py b/backend/grants_shared/tests/grants_shared/util/test_string_utils.py new file mode 100644 index 0000000..9c0dc8b --- /dev/null +++ b/backend/grants_shared/tests/grants_shared/util/test_string_utils.py @@ -0,0 +1,114 @@ +import pytest + +from grants_shared.util.string_utils import is_valid_uuid, join_list, truncate_html_inline + + +def test_join_list(): + assert join_list(None) == "" + assert join_list(None, ",") == "" + assert join_list(None, "|") == "" + assert join_list([]) == "" + assert join_list([], ",") == "" + assert join_list([], "|") == "" + + assert join_list(["a", "b", "c"]) == "a\nb\nc" + assert join_list(["a", "b", "c"], ",") == "a,b,c" + assert join_list(["a", "b", "c"], "|") == "a|b|c" + + +@pytest.mark.parametrize( + "value,is_valid", + [ + ("20f5484b-88ae-49b0-8af0-3a389b4917dd", True), + ("abc123", False), + ("1234", False), + ("xyz", False), + ], +) +def test_is_valid_uuid(value, is_valid): + assert is_valid_uuid(value) is is_valid + + +@pytest.mark.parametrize( + "html_str,expected_html", + [ + # Truncate mid-text inside inline tag , no closing tag present + ( + "

This is a very big description, here!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", + '

This is a very big description, here!!!!!!!!!!!!!!...Read full description

', + ), + # Truncate mid-text inside multiple nested inline tags + ( + "

Some bold and emphasized text here EXTRA WORDS WORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS

", + '

Some bold and emphasized text here EXTRA WORDS WOR...Read full description

', + ), + # Truncate inside deeply nested block tags (block tags intentionally left open) + ( + "
first
second
third and some extra trailing content EXTRA WORDS EXTRA WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS
", + '
first
second
third and some extra trailing content E...Read full description
', + ), + # Broken / mismatched HTML, truncate after inline content + ( + "

Hello

i am here!!!

EXTRA TEXT EXTRA WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS EXTRA WORDS", + '

Hello

i am here!!!

EXTRA TEXT EXTRA WORDS ORDS WOR...Read full description

', + ), + # Self-closing tag inside truncated text (e.g.
) + ( + "
This is a description
with a break tag and more words here EXTRA WORDS EXTRA WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS
", + '
This is a description
with a break tag and more wor...Read full description
', + ), + # Deep nesting with inline tags, truncate mid-inline + ( + "

This is a deeply nested example with text EXTRA WORDS EXTRA WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS ORDS WORDS WORDS", + '

This is a deeply nested example with text EXTRA WO...Read full description

', + ), + # Malformed HTML + ( + "

This is a very long broken inline section that exceeds the fifty character truncation limit easily", + '

This is a very long broken inline section that exc...Read full description

', + ), + # Deep malformed nesting with missing closing tags + ( + "

First paragraph with enough content to exceed fifty characters easily

Second level still open", + '

First paragraph with enough content to exceed fift...Read full description

Second level still open

', + ), + # Text split across inline tags exceeding 50 characters + ( + "

This text is split across multiple inline elements and continues further beyond limit

", + '

This text is split across multiple inline elements...Read full description beyond limit

', + ), + # Contains script and long visible text + ( + "
This visible content is definitely longer than fifty characters and should truncate properly.
", + '
This visible content is definitely long...Read full description
', + ), + # HTML entities count as characters + ( + "

This content contains entities and continues well beyond fifty characters total.

", + '

This\xa0content\xa0contains\xa0entities\xa0and continues well...Read full description

', + ), + # Self-closing tags with long text after + ( + "
Line one
Line two
Line three continues long enough to exceed the fifty character truncation point easily.
", + '
Line one
Line two
Line three continues long enough t...Read full description
', + ), + # Multiple sibling blocks, truncation in first + ( + "
First block with enough content to exceed fifty characters before the next div.
Second block
", + '
First block with enough content to exceed fifty ch...Read full description
Second block
', + ), + # No truncation needed + ("hello", "hello"), + ], +) +def test_truncate_html_inline( + html_str, + expected_html, +): + + res = truncate_html_inline( + html_str, + 50, + "...Read full description", + ) + assert res == expected_html diff --git a/backend/grants_shared/uv.lock b/backend/grants_shared/uv.lock new file mode 100644 index 0000000..e2d1b86 --- /dev/null +++ b/backend/grants_shared/uv.lock @@ -0,0 +1,1618 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[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 = "apiflask" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec" }, + { name = "flask" }, + { name = "flask-httpauth" }, + { name = "flask-marshmallow" }, + { name = "marshmallow" }, + { name = "pydantic", extra = ["email"] }, + { name = "webargs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/ef/78b445e7dfd2867651df27d478f5f1e5dce3e65dca6146d4db9719ed89d6/apiflask-3.1.0.tar.gz", hash = "sha256:44ac6de494054e6988afea9d1b9272c36e83ecc8a64c7fbab0b877c0d3820d0f", size = 113717, upload-time = "2026-03-22T02:40:59.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e2/0c5d93c68c81398b65579e2cc1e03c307c4c9f9cf0f3a34a2ec551f729a9/apiflask-3.1.0-py3-none-any.whl", hash = "sha256:d7eb6bb8565ef253f0b3898d67bb04993eda1cdefdaffde3ed78b41220645f6d", size = 57788, upload-time = "2026-03-22T02:40:58.386Z" }, +] + +[[package]] +name = "apispec" +version = "6.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/f1/1f5a9332df3ecd90cc5ab69bc58a4174b8ba2ac1720c4c26b01d20751bf5/apispec-6.10.0.tar.gz", hash = "sha256:0a888555cd4aa5fb7176041be15684154fd8961055e1672e703abf737e8761bf", size = 80631, upload-time = "2026-03-06T21:48:40.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313, upload-time = "2026-03-06T21:48:39.404Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +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/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[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/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { 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/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { 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 = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[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.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-httpauth" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/f4/6957215e827021eeb7d8de9f59b1864d73933b04851e59272708cb6e5d2b/flask_httpauth-4.8.1.tar.gz", hash = "sha256:88499b22f1353893743c3cd68f2ca561c4ad9ef75cd6bcc7f621161cd0e80744", size = 38993, upload-time = "2026-03-28T19:45:24.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/da/624c87bf6c13107ceab8ee23815d9468e47d89c7480c1dc9af39b08eb290/flask_httpauth-4.8.1-py3-none-any.whl", hash = "sha256:0080393d70e12327781f7509115175ec5e47209816489a620d4fd39e20cea2e8", size = 9651, upload-time = "2026-03-28T19:45:23.155Z" }, +] + +[[package]] +name = "flask-marshmallow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "marshmallow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/43/6e5c19e8abc01f5daf1d3c8ad169c495335390572b8bead3f7e7302131c6/flask_marshmallow-1.4.0.tar.gz", hash = "sha256:98c90a253052c72d2ddddc925539ac33bbd780c6fba86478ffe18e3b89d8b471", size = 40970, upload-time = "2026-02-04T16:07:59.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/9b/7d0605c6f90d640547c3c9a0b95bc3bb17e252ae0f46f1dbfa90d2e06518/flask_marshmallow-1.4.0-py3-none-any.whl", hash = "sha256:b758fc2c428d0cbee6fd0ccf0d55524fe9e426a86a177dcc0fc8cd71ad4b7c59", size = 12254, upload-time = "2026-02-04T16:07:58.878Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "grants-shared" +version = "0.3.0" +source = { virtual = "." } +dependencies = [ + { name = "apiflask" }, + { name = "beautifulsoup4" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "jsonpath-ng" }, + { name = "jsonref" }, + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "marshmallow" }, + { name = "newrelic" }, + { name = "pandas" }, + { name = "pandas-stubs" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, + { name = "pytz" }, + { name = "smart-open" }, + { name = "sqlalchemy", extra = ["mypy"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "black" }, + { name = "coverage" }, + { name = "debugpy" }, + { name = "factory-boy" }, + { name = "faker" }, + { name = "freezegun" }, + { name = "isort" }, + { name = "moto", extra = ["s3"] }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "apiflask", specifier = ">=3.1.0,<4" }, + { name = "beautifulsoup4", specifier = ">=4.14.3,<5" }, + { name = "boto3", specifier = ">=1.43.3,<2" }, + { name = "botocore", specifier = ">=1.43.3,<2" }, + { name = "jsonpath-ng", specifier = ">=1.8.0,<2" }, + { name = "jsonref", specifier = ">=1.1.0,<2" }, + { name = "jsonschema", extras = ["format-nongpl"], specifier = ">=4.26.0,<5" }, + { name = "marshmallow", specifier = ">=3.20.1,<4" }, + { name = "newrelic", specifier = ">=12.1.0,<13" }, + { name = "pandas", specifier = ">=2.0.3,<3" }, + { name = "pandas-stubs", specifier = ">=2.0.3,<3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4,<4" }, + { name = "pydantic", specifier = ">=2.13.3,<3" }, + { name = "pydantic-settings", specifier = ">=2.14.0,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1,<3" }, + { name = "python-dotenv", specifier = ">=1.2.2,<2" }, + { name = "pytz", specifier = ">=2026.2,<2027" }, + { name = "smart-open", specifier = ">=7.6.0,<8" }, + { name = "sqlalchemy", extras = ["mypy"], specifier = ">=2.0.49,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.9.4,<2" }, + { name = "black", specifier = ">=26.3.1,<27" }, + { name = "coverage", specifier = ">=7.13.5,<8" }, + { name = "debugpy", specifier = ">=1.8.20,<2" }, + { name = "factory-boy", specifier = ">=3.3.3,<4" }, + { name = "faker", specifier = ">=40.15.0,<41" }, + { name = "freezegun", specifier = ">=1.5.5,<2" }, + { name = "isort", specifier = ">=8.0.1,<9" }, + { name = "moto", extras = ["s3"], specifier = ">=5.2.0,<6" }, + { name = "mypy", specifier = ">=1.20.2,<2" }, + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "ruff", specifier = ">=0.15.12,<16" }, + { name = "types-requests", specifier = ">=2.33.0.20260503" }, +] + +[[package]] +name = "greenlet" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, + { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, + { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, + { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[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 = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpath-ng" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "moto" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "cryptography" }, + { name = "requests" }, + { name = "responses" }, + { name = "werkzeug" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" }, +] + +[package.optional-dependencies] +s3 = [ + { name = "py-partiql-parser" }, + { name = "pyyaml" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[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 = "newrelic" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/16/4dd1efc8443e50ddb7f3b83ea35a45b08ded6e9bd4694e73720d90df9b8b/newrelic-12.1.0.tar.gz", hash = "sha256:309e515ae3cb7981919dd2eaf9d2c0510e46e3e3a60b915e5c79b8257ececa82", size = 1379969, upload-time = "2026-03-26T21:50:12.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/7d/1f53b4a73619d39ee18cdbb1003b24c153ac846ebaaccfa37e20d6886306/newrelic-12.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65eaa0eb614594c9892601f7a0e4e7b9c6efbc16f2c95aeb10178c5a693cd344", size = 920511, upload-time = "2026-03-26T21:49:45.131Z" }, + { url = "https://files.pythonhosted.org/packages/33/b0/bf5219829f0b2f3bfdbcea5171a022ad6f4c979f1f430bff59ad52f4f716/newrelic-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b2bc236fec4fbfcd9e53b3bb7f63344fc89b3b2021dece5608ee6f009845bf", size = 924050, upload-time = "2026-03-26T21:49:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/00/2c/9ae170a9e13bc4c9125a7c85b7d0a031c69b3c7dbe51861b2ee9b7dcdee1/newrelic-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6cb2c1b7d93a3c7945bed0d9c61f91dab1e93de8f803679db76ca2056b864ec4", size = 921878, upload-time = "2026-03-26T21:49:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/fecad76aa1b9e4aa7572d15dd42f9b65a909027953ad83b852e1bb84feef/newrelic-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f220830707cb4af47d403b22c5934854ac1331460ddda9204d42882b55059dde", size = 919652, upload-time = "2026-03-26T21:49:50.24Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/96bb83fc0919843aaa161ff02d1a78ab58574e8e63cb2c09d88fa71b20de/newrelic-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:13d4ec1f00db7868237d30cc92d4191139b38b5215f0f0ff194736749800ccc6", size = 853352, upload-time = "2026-03-26T21:49:52.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/ae/489d56517d58844cd53facd7c36ced6458e884988ceae9a14a19988cea05/newrelic-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a2057b4379c6f8e6349840433998c9df7abc1175b7ec66229e70ce0a6b66aa0d", size = 850549, upload-time = "2026-03-26T21:49:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/da/10/7f844635367fcf9721970a850105affc7793f749f80862f0fc20b42e4657/newrelic-12.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f32c4d5cae4bea17d385480f86ca98d841480af166243271b233244c3c7a4e0", size = 955001, upload-time = "2026-03-26T21:49:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/f24078c8aa40ba6340faa3ababf0503c47d5c73944b102254c7a3db8493b/newrelic-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:214a06e33af8c973909b272a9508feaa11feddb924494157944cdf8dd51c8239", size = 962889, upload-time = "2026-03-26T21:49:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/67/6efae990302d435511867a83e2c61433489d421355fca1388c8e9fd950c3/newrelic-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4dc1833bbd271390cbf9a671f50566924344efdd68025dca12230321d3d5dc2d", size = 958832, upload-time = "2026-03-26T21:49:59.088Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/72cae97164de845590c23d9c9b930a4242ec7aa7cc44454ea07ded5697be/newrelic-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3fbd2adcf20f4ef0cae3379feb0c354c1d34ac139035d1f21f69669f488a907d", size = 952482, upload-time = "2026-03-26T21:50:00.561Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/97cf773ff6921353c109eb6763313651d91da38302bde9f62f8d7c813410/newrelic-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bf618a355b49aac3118266aa743a159201247266f330bccd7c0035a9939c396a", size = 856775, upload-time = "2026-03-26T21:50:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/cf/60db73f4af7c5e4fe06a926b2553e51ae0fe5a5fc60ba80c18c22932950d/newrelic-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1b37f10844fd27b81d80351d1c3623e0fda434b86b01e3942ff4511161df5eee", size = 852149, upload-time = "2026-03-26T21:50:03.919Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "2.3.3.260113" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "types-pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/5d/be23854a73fda69f1dbdda7bc10fbd6f930bd1fa87aaec389f00c901c1e8/pandas_stubs-2.3.3.260113.tar.gz", hash = "sha256:076e3724bcaa73de78932b012ec64b3010463d377fa63116f4e6850643d93800", size = 116131, upload-time = "2026-01-13T22:30:16.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/df1fe324248424f77b89371116dab5243db7f052c32cc9fe7442ad9c5f75/pandas_stubs-2.3.3.260113-py3-none-any.whl", hash = "sha256:ec070b5c576e1badf12544ae50385872f0631fc35d99d00dc598c2954ec564d3", size = 168246, upload-time = "2026-01-13T22:30:15.244Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[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 = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "py-partiql-parser" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/7a/a0f6bda783eb4df8e3dfd55973a1ac6d368a89178c300e1b5b91cd181e5e/py_partiql_parser-0.6.3.tar.gz", hash = "sha256:09cecf916ce6e3da2c050f0cb6106166de42c33d34a078ec2eb19377ea70389a", size = 17456, upload-time = "2025-10-18T13:56:13.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl", hash = "sha256:deb0769c3346179d2f590dcbde556f708cdb929059fb654bad75f4cf6e07f582", size = 23752, upload-time = "2025-10-18T13:56:12.256Z" }, +] + +[[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.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[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/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 = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[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/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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + +[[package]] +name = "responses" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/65/3ada667d32675399001bf022ad3d9f3989b57101351ebc71d6fbe2384634/smart_open-7.6.1.tar.gz", hash = "sha256:4347996e7ba21db7cd1e059632e0b30395407e4f6c660d2ddffc8f2a9ae5f990", size = 54754, upload-time = "2026-05-09T06:23:37.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/78/0f68b93564b8c6b6987a0696c582ba2591a381ab2f733a501909e949f241/smart_open-7.6.1-py3-none-any.whl", hash = "sha256:b4de6aebef023aca91cc9fb372052e1343ba3f152de215bd22391a663e3ddd21", size = 64845, upload-time = "2026-05-09T06:23:35.386Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[package.optional-dependencies] +mypy = [ + { name = "mypy" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "types-pytz" +version = "2026.2.0.20260506" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/2e/a545aae2c4d2af7e3b7b967049c2f271b2f5338d96a58224fe1ee53a54f3/types_pytz-2026.2.0.20260506.tar.gz", hash = "sha256:fc6a0de6a1b7da82a748fb4065e152372dac3016559cb1eef5e8af1e338eb627", size = 10844, upload-time = "2026-05-06T05:17:51.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/cd/df3e4ccccb2a5a0b7e59c9fb2baafb6dac0817e80799e4c9854fe4d2eba3/types_pytz-2026.2.0.20260506-py3-none-any.whl", hash = "sha256:58ab5307c20885f9bcd42ff106616eb0e32710791f8cbdc770aee2ea0c4f01fb", size = 10120, upload-time = "2026-05-06T05:17:51.026Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260508" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" }, +] + +[[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 = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "webargs" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/64/17afc4e6f47eef154a553c6e56adcc9f1ac3003305c7df978d11aa62937e/webargs-8.7.1.tar.gz", hash = "sha256:799bf9039c76c23fd8dc1951107a75a9e561203c15d6ae8f89c1e46e234636c1", size = 97351, upload-time = "2025-10-29T16:07:50.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ef/b0d17f3943429358184449771b592e0e1d33bbeaa6ed326434a95eac187b/webargs-8.7.1-py3-none-any.whl", hash = "sha256:a184aed9d2509e6e14ab99ee3e9dc3a614c7070affe94cd4dfdb0d002e0a6e5f", size = 32500, upload-time = "2025-10-29T16:07:47.895Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "xmltodict" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, +] diff --git a/documentation/README.md b/documentation/README.md index eafc3bd..44bbe53 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -1,3 +1,3 @@ # Documentation for Grants Shared -This folder contains documentation for the Grants Shared. \ No newline at end of file +This folder contains documentation for Grants Shared. \ No newline at end of file