diff --git a/.gitignore b/.gitignore index 249cd86aa..2b2a23208 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ coverage.xml mpcontribs-api/supervisord.conf mpcontribs-portal/supervisord.conf **/.DS_Store +.venv/ +.env +.claude/ diff --git a/mpcontribs-api/.env.example b/mpcontribs-api/.env.example new file mode 100644 index 000000000..2bc5ce6f6 --- /dev/null +++ b/mpcontribs-api/.env.example @@ -0,0 +1,11 @@ +MPCONTRIBS_ENVIRONMENT=dev + +MPCONTRIBS_MONGO__URI=mongodb+srv://:@host.hash.mongodb.net/?appName=database-name +MPCONTRIBS_MONGO__DB_NAME=database-name + +MPCONTRIBS_REDIS__ADDRESS=redis-address # placeholder; not used yet +MPCONTRIBS_REDIS__URL=redis-url + +MPCONTRIBS_MAIL_DEFAULT_SENDER=mail-default-sender # placeholder; not used yet + +MPCONTRIBS_VERSION=version diff --git a/mpcontribs-api/.gitignore b/mpcontribs-api/.gitignore new file mode 100644 index 000000000..edd9d60a7 --- /dev/null +++ b/mpcontribs-api/.gitignore @@ -0,0 +1,2 @@ +build/ +dist/ diff --git a/mpcontribs-api/CLAUDE.md b/mpcontribs-api/CLAUDE.md new file mode 100644 index 000000000..ef6f146b6 --- /dev/null +++ b/mpcontribs-api/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +This is a complete rewrite of the MPContribs API server. +The core of the package is FastAPI, Pydantic, and Beanie. +Developer experience and process efficiency are top priorities. + +## Commands + +```bash +# Format, fix, and lint (preferred) +just fmt + +# Run tests +uv run pytest + +# Run tests by marker +uv run pytest -m base +uv run pytest -m extra + +# Run tests in parallel +uv run pytest -n auto + +# Type check +uv run basedpyright +``` + +## Environment + +Copy `.env.example` to `.env`. Key variables (all prefixed `MPCONTRIBS_`): + +- `MONGO__URI` — MongoDB Atlas URI +- `MONGO__DB_NAME` — database name +- `ENVIRONMENT` — `dev` or `prod` (controls log format and debug mode) + +## Architecture + +### Domain structure + +Each resource lives in `src/mpcontribs_api/domains//` with four files: + +- `models.py` — Beanie `Document` subclass (DB model) + Pydantic output/input/patch/filter models +- `repository.py` — `MongoDbRepository` encapsulating all database access +- `router.py` — `APIRouter` with CRUD endpoint handlers +- `dependencies.py` — `Dep` type alias (`Annotated[MongoDbRepository, Depends(...)]`) + +Routers are registered in `src/mpcontribs_api/api/v1/router.py`. + +### Authentication and authorization + +Kong injects user identity via headers; `dependencies.get_user` parses them into a frozen `User` model (`authz.py`). The dependency chain is: + +- `UserDep` — any caller (anonymous or authenticated) +- `AuthedDep` / `require_user` — requires an authenticated user (raises 401 otherwise) +- `require_role(role)` — factory returning a dependency that requires a specific group membership + +All mutating endpoints (POST/PUT/PATCH/DELETE) depend on `require_user`, so anonymous callers get 401; read endpoints (GET) stay open and rely on scope to filter results. + +All database access goes through a repository instantiated with the current `User`. The repository's `_scope` dict is injected into every MongoDB query automatically: + +- **Admins** (members of `mongo.admin_group`): no filter applied +- **Authenticated users**: see public+approved data, own resources (`owner == username`), and group resources +- **Anonymous**: public + approved only + +Components (structures/tables/attachments) have no access field of their own; their reads and deletes are gated by whether an in-scope contribution references them (see `ContributionService`/`ComponentService`). + +**Trust boundary:** the service is only reachable through Kong, which terminates auth and sets the identity headers. There is no in-app gateway-secret check today — the deployment network is the boundary, so the identity headers are trusted. If the service is ever exposed off the Kong path, add a gateway-secret (or mTLS) check before trusting those headers. + +### Repository pattern + +Repositories take a `User` at construction time and expose typed async methods (`get_*`, `insert_*`, `patch_*`, `upsert_*`, `delete_*`). They never leak the scope logic to routers — routers only call repository methods. + +### Sparse field selection + +The `_fields` query parameter (handled in `projection.py`) lets callers request specific fields (comma-separated, dotted for nesting). `SparseFieldsModel` dynamically creates a trimmed Pydantic model via `create_model()` and converts field paths to MongoDB projection syntax. Results are cached with `@lru_cache`. + +### Cursor-based pagination + +`Page[T]` in `pagination.py` contains `items` and a `next_cursor` (base64-encoded last item ID). Pagination is forward-only and stateless. Default page size is 20; max is 100. + +### Exception hierarchy + +`exceptions.py` defines `AppError` subclasses (`NotFoundError`, `ConflictError`, `ValidationError`, `AuthenticationError`, `PermissionError`). All carry `status_code`, `error_code`, `message`, and a `context` dict. Handlers in `app.py` convert them to a uniform JSON shape; internal context is logged but not sent to clients. + +### Observability + +`logging.py` configures structlog with per-request context vars (request_id, method, path, consumer_id) bound by the middleware in `middleware.py`. OpenTelemetry traces are exported via OTLP/gRPC and trace/span IDs are injected into every log line. diff --git a/mpcontribs-api/Dockerfile b/mpcontribs-api/Dockerfile index 88be9c146..0e68e2778 100644 --- a/mpcontribs-api/Dockerfile +++ b/mpcontribs-api/Dockerfile @@ -1,58 +1,43 @@ -FROM materialsproject/devops:python-3.1113.4 AS base -RUN apt-get update && apt-get install -y --no-install-recommends supervisor libopenblas-dev libpq-dev vim && apt-get clean +# FROM materialsproject/devops:python-3.1113.4 AS base +FROM python:3.14-slim AS base +RUN apt-get update && apt-get install -y --no-install-recommends supervisor libopenblas-dev vim && apt-get clean WORKDIR /app FROM base AS builder -RUN apt-get update && apt-get install -y --no-install-recommends gcc git g++ libsnappy-dev wget liblapack-dev && apt-get clean -ENV PIP_FLAGS "--no-cache-dir --compile" -COPY requirements/deployment.txt ./requirements.txt -RUN pip install $PIP_FLAGS -r requirements.txt -COPY pyproject.toml . -COPY mpcontribs mpcontribs +RUN apt-get update && apt-get install -y --no-install-recommends gcc git g++ wget liblapack-dev libsnappy-dev && apt-get clean +RUN pip install uv +# Build the venv OUTSIDE /app: docker-compose bind-mounts the host source over +# /app for live reload, which would mask a /app/.venv and leave `python` falling +# back to the system interpreter (missing deps) +ENV UV_PROJECT_ENVIRONMENT=/opt/venv +COPY pyproject.toml uv.lock ./ +COPY src src ARG CONTRIBS_VERSION -RUN SETUPTOOLS_SCM_PRETEND_VERSION=${CONTRIBS_VERSION} pip install $PIP_FLAGS --no-deps . -#ENV SETUPTOOLS_SCM_PRETEND_VERSION 0.0.0 -#COPY marshmallow-mongoengine marshmallow-mongoengine -#RUN cd marshmallow-mongoengine && pip install $PIP_FLAGS --no-deps -e . -#COPY mimerender mimerender -#RUN cd mimerender && pip install $PIP_FLAGS --no-deps -e . -#COPY flask-mongorest flask-mongorest -#RUN cd flask-mongorest && pip install $PIP_FLAGS --no-deps -e . -#COPY AtlasQ AtlasQ -#RUN cd AtlasQ && pip install $PIP_FLAGS --no-deps -e . +RUN SETUPTOOLS_SCM_PRETEND_VERSION=${CONTRIBS_VERSION} uv sync --frozen --no-dev --no-editable RUN wget -q https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \ - chmod +x wait-for-it.sh && mv wait-for-it.sh /usr/local/bin/ && \ - wget -q https://github.com/materialsproject/MPContribs/blob/master/mpcontribs-api/mpcontribs/api/contributions/formulae.json.gz?raw=true \ - -O mpcontribs/api/contributions/formulae.json.gz + chmod +x wait-for-it.sh && mv wait-for-it.sh /usr/local/bin/ FROM base ARG BUILDARCH -ENV BUILDARCH=x86_64 -COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin -COPY --from=builder /usr/lib/$BUILDARCH-linux-gnu/libsnappy* /usr/lib/$BUILDARCH-linux-gnu/ +ARG BUILDARCH=aarch64 +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /usr/local/bin/wait-for-it.sh /usr/local/bin/ -COPY --from=builder /app/mpcontribs/api /app/mpcontribs/api WORKDIR /app RUN mkdir -p /var/log/supervisor COPY supervisord supervisord COPY scripts scripts COPY main.py . -COPY maintenance.py . COPY docker-entrypoint.sh . -COPY gunicorn.conf.py . RUN chmod +x main.py scripts/start.sh scripts/start_rq.sh docker-entrypoint.sh ARG VERSION -ENV DD_SERVICE=contribs-apis \ - DD_ENV=prod \ - DD_VERSION=$VERSION \ - DD_TRACE_HOST=localhost:8126 \ - DD_TRACE_OTEL_ENABLED=false \ - DD_MAIN_PACKAGE=mpcontribs-api +# Telemetry (traces/metrics/logs) is emitted via OTLP to the Datadog Agent's OTLP receiver; the +# endpoint and other OTEL settings are configured per-environment in supervisord.conf.jinja +# (MPCONTRIBS_OTEL__*). No Datadog-specific app config lives here. +ENV PATH="/opt/venv/bin:$PATH" -LABEL com.datadoghq.ad.logs='[{"source": "gunicorn", "service": "contribs-apis"}]' EXPOSE 10000 10002 10003 10005 20000 ENTRYPOINT ["/app/docker-entrypoint.sh"] CMD ["/usr/bin/supervisord", "-c", "supervisord.conf"] diff --git a/mpcontribs-api/gunicorn.conf.py b/mpcontribs-api/gunicorn.conf.py deleted file mode 100644 index d6d1693c1..000000000 --- a/mpcontribs-api/gunicorn.conf.py +++ /dev/null @@ -1,16 +0,0 @@ -import ddtrace.auto # noqa: F401 -import os - -bind = "0.0.0.0:{}".format(os.getenv("API_PORT")) -worker_class = "gevent" -workers = os.getenv("NWORKERS") -statsd_host = "{}:8125".format(os.getenv("DD_AGENT_HOST")) -accesslog = "-" -errorlog = "-" -access_log_format = '{}/{}: %(h)s %(t)s %(m)s %(U)s?%(q)s %(H)s %(s)s %(b)s "%(f)s" "%(a)s" %(D)s %(p)s %({{x-consumer-id}}i)s'.format( - os.getenv("SUPERVISOR_GROUP_NAME"), os.getenv("SUPERVISOR_PROCESS_NAME") -) -max_requests = os.getenv("MAX_REQUESTS") -max_requests_jitter = os.getenv("MAX_REQUESTS_JITTER") -proc_name = os.getenv("SUPERVISOR_PROCESS_NAME") -reload = bool(os.getenv("RELOAD", False)) diff --git a/mpcontribs-api/justfile b/mpcontribs-api/justfile new file mode 100644 index 000000000..afb9e566c --- /dev/null +++ b/mpcontribs-api/justfile @@ -0,0 +1,20 @@ +# Run the dev server (auto-reloads on file changes) +dev: + uv run fastapi dev src/mpcontribs_api/app.py + +# Format, fix, and lint all source code +fmt: + uv run ruff format + -uv run ruff check --fix + -uv run ruff check --fix --unsafe-fixes + -uv run pydocstringformatter --write + +test suite="all": + #!/usr/bin/env bash + case "{{suite}}" in + all|a) uv run pytest tests/ ;; + integration|i) uv run pytest tests/integration/ -m "not db" ;; + unit|u) uv run pytest tests/unit/ ;; + db|d) uv run pytest tests/integration/db/ -m db ;; + *) echo "unknown suite '{{suite}}' — use: all/a, integration/i, unit/u, db/d" && exit 1 ;; + esac diff --git a/mpcontribs-api/main.py b/mpcontribs-api/main.py index 2ec96e9fc..d9d5a740c 100755 --- a/mpcontribs-api/main.py +++ b/mpcontribs-api/main.py @@ -1,14 +1,14 @@ #!/usr/bin/env python -import os -import requests -import boto3 import logging +import os +import boto3 +import requests from supervisor.options import ClientOptions from supervisor.supervisorctl import Controller logger = logging.getLogger(__name__) -client = boto3.client('ecs') +client = boto3.client("ecs") def start(program): diff --git a/mpcontribs-api/maintenance.py b/mpcontribs-api/maintenance.py deleted file mode 100644 index d6c4682a0..000000000 --- a/mpcontribs-api/maintenance.py +++ /dev/null @@ -1,41 +0,0 @@ -from boltons.iterutils import remap -from mongoengine.queryset.visitor import Q - -from mpcontribs.api import enter -from mpcontribs.api.projects.document import Projects -from mpcontribs.api.contributions.document import Contributions - - -def visit(path, key, value): - if isinstance(value, dict) and "display" in value: - return key, value["display"] - return True - - -def fix_units(name): - # make sure correct units are indicated in project.columns before running this - fields = list(Contributions._fields.keys()) - project = Projects.objects.with_id(name).reload("columns") - query = Q() - - for column in project.columns: - if column.unit and column.unit != "NaN": - path = column.path.replace(".", "__") - q = {f"{path}__unit__ne": column["unit"]} - query |= Q(**q) - - contribs = Contributions.objects(Q(project=name) & query).only(*fields) - num = contribs.count() - print(name, num) - - for idx, contrib in enumerate(contribs): - contrib.data = remap(contrib.data, visit=visit, enter=enter) # pull out display - contrib.save(signal_kwargs={"skip": True}) # reparse display with intended unit - - if idx and not idx%250: - print(idx) - -# additional maintenance functions -# TODO generate JSON/CSV project downloads -# TODO clean dangling notebooks -# TODO update_projects/stats diff --git a/mpcontribs-api/mpcontribs/api/__init__.py b/mpcontribs-api/mpcontribs/api/__init__.py deleted file mode 100644 index e61583b4b..000000000 --- a/mpcontribs-api/mpcontribs/api/__init__.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- coding: utf-8 -*- -"""Flask App for MPContribs API""" -import os -import smtplib -import logging -import requests -import flask_mongorest.operators as ops - -from email.message import EmailMessage -from importlib import import_module -from importlib.metadata import version -from websocket import create_connection -from flask import Flask, current_app, request, jsonify -from flask_marshmallow import Marshmallow -from flask_mongoengine import MongoEngine -from flask_mongorest import register_class -from flask_sse import sse -from flask_compress import Compress -from flasgger.base import Swagger - -from mongoengine import ValidationError -from mongoengine.base.datastructures import BaseDict -from itsdangerous import URLSafeTimedSerializer -from string import punctuation, whitespace -from boltons.iterutils import remap, default_enter -from notebook.utils import url_path_join -from notebook.gateway.managers import GatewayClient -from requests.exceptions import ConnectionError, Timeout - -try: - __version__ = version("mpcontribs-api") -except Exception: - # package is not installed - pass - -delimiter, max_depth = ".", 7 # = MAX_NESTING + 2 from client -invalidChars = set(punctuation.replace("*", "").replace("|", "") + whitespace) -is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") -SMTP_HOST, SMTP_PORT = os.environ.get("SMTP_SERVER", "localhost:587").split(":") - -# NOTE not including Size below (special for arrays) -FILTERS = { - "LONG_STRINGS": [ - ops.Contains, - ops.IContains, - ops.Startswith, - ops.IStartswith, - ops.Endswith, - ops.IEndswith, - ], - "NUMBERS": [ops.Lt, ops.Lte, ops.Gt, ops.Gte, ops.Range], - "DATES": [ops.Before, ops.After], - "OTHERS": [ops.Boolean, ops.Exists], -} -FILTERS["STRINGS"] = [ops.In, ops.Exact, ops.IExact, ops.Ne] + FILTERS["LONG_STRINGS"] -FILTERS["ALL"] = ( - FILTERS["STRINGS"] + FILTERS["NUMBERS"] + FILTERS["DATES"] + FILTERS["OTHERS"] -) - - -class CustomLoggerAdapter(logging.LoggerAdapter): - def process(self, msg, kwargs): - prefix = self.extra.get("prefix") - return f"[{prefix}] {msg}" if prefix else msg, kwargs - - -def get_logger(name): - logger = logging.getLogger(name) - process = os.environ.get("SUPERVISOR_PROCESS_NAME") - group = os.environ.get("SUPERVISOR_GROUP_NAME") - cfg = {"prefix": f"{group}/{process}"} if process and group else {} - logger.setLevel("DEBUG" if os.environ.get("FLASK_DEBUG") else "INFO") - return CustomLoggerAdapter(logger, cfg) - - -logger = get_logger(__name__) - - -def enter(path, key, value): - if isinstance(value, BaseDict): - return dict(), value.items() - elif isinstance(value, list): - dot_path = delimiter.join(list(path) + [key]) - raise ValidationError(f"lists not allowed ({dot_path})!") - - return default_enter(path, key, value) - - -def valid_key(key): - if not key.isascii(): - raise ValidationError(f"non-ascii character(s) in {key}") - - for char in key: - if char in invalidChars: - raise ValidationError(f"invalid character {char} in {key}") - - if key.count("|") > 1: - raise ValidationError(f"Only one `|` allowed in {key}. Consider nesting.") - - -def visit(path, key, value): - key = key.strip() - - if len(path) + 1 > max_depth: - dot_path = delimiter.join(list(path) + [key]) - raise ValidationError(f"max nesting ({max_depth}) exceeded for {dot_path}") - - valid_key(key) - return key, value - - -def valid_dict(dct): - remap(dct, visit=visit, enter=enter) - - -def send_email(to, subject, html): - msg = EmailMessage() - msg.set_content(html) - msg["Subject"] = subject - msg["From"] = current_app.config["MAIL_DEFAULT_SENDER"] - msg["To"] = to - - # NOTE boto3 SES client can't connect to VPC endpoint yet - with smtplib.SMTP(host=SMTP_HOST, port=int(SMTP_PORT)) as ses_client: - ses_client.starttls() - ses_client.login(os.environ["SMTP_USERNAME"], os.environ["SMTP_PASSWORD"]) - ses_client.send_message(msg) - logger.warning(f"Email with subject `{subject}` sent to `{to}`") - - -def get_collections(db): - """get list of collections in DB""" - conn = db.app.extensions["mongoengine"][db]["conn"] - dbname = db.app.config.get("MPCONTRIBS_DB") - return conn[dbname].list_collection_names() - - -def get_resource_as_string(name, charset="utf-8"): - """http://flask.pocoo.org/snippets/77/""" - with current_app.open_resource(name) as f: - return f.read().decode(charset) - - -def get_kernel_endpoint(kernel_id=None, ws=False): - gw_client = GatewayClient.instance() - base_url = gw_client.ws_url if ws else gw_client.url - - if not base_url: - raise ConnectionError("base URL for Kernel Gateway not set") - - base_endpoint = url_path_join(base_url, gw_client.kernels_endpoint) - - if isinstance(kernel_id, str) and kernel_id: - return url_path_join(base_endpoint, kernel_id) - - return base_endpoint - - -def create_kernel_connection(kernel_id): - url = get_kernel_endpoint(kernel_id, ws=True) + "/channels" - return create_connection(url, skip_utf8_validation=True) - - -def get_kernels(): - """retrieve list of kernels from KernelGateway service""" - try: - r = requests.get(get_kernel_endpoint(), timeout=2) - except (ConnectionError, Timeout): - logger.warning("Kernel Gateway NOT AVAILABLE") - return None - - response = r.json() - nkernels = 3 # reserve 3 kernels for each deployment - idx = int(os.environ.get("DEPLOYMENT", 1)) - - if len(response) < nkernels * (idx + 1): - logger.error("NOT ENOUGH KERNELS AVAILABLE") - return None - - return {kernel["id"]: None for kernel in response[idx : idx + 3]} - - -def get_consumer(): - groups = request.headers.get("X-Authenticated-Groups", "").split(",") - groups += request.headers.get("X-Consumer-Groups", "").split(",") - return { - "username": request.headers.get("X-Consumer-Username"), - "apikey": request.headers.get("X-Consumer-Custom-Id"), - "groups": ",".join(set(groups)), - } - - -def create_app(): - """create flask app""" - app = Flask(__name__) - app.config.from_pyfile("config.py", silent=True) - app.config["USTS"] = URLSafeTimedSerializer(app.secret_key) - app.jinja_env.globals["get_resource_as_string"] = get_resource_as_string - app.jinja_env.lstrip_blocks = True - app.jinja_env.trim_blocks = True - app.json.sort_keys = False - MPCONTRIBS_API_HOST = app.config.get("MPCONTRIBS_API_HOST") - app.config["TEMPLATE"]["schemes"] = ["http"] if app.debug else ["https"] - logger.info("database: " + app.config["MPCONTRIBS_DB"]) - Compress(app) - Marshmallow(app) - MongoEngine(app) - Swagger(app, template=app.config.get("TEMPLATE")) - setattr(app, "kernels", get_kernels()) - - # NOTE: hard-code to avoid pre-generating for new deployment - # collections = get_collections(db) - collections = [ - "projects", - "contributions", - "tables", - "attachments", - "structures", - "notebooks", - ] - - for collection in collections: - module_path = ".".join(["mpcontribs", "api", collection, "views"]) - try: - module = import_module(module_path) - except ModuleNotFoundError as ex: - logger.error(f"API module {module_path}: {ex}") - continue - - try: - blueprint = getattr(module, collection) - app.register_blueprint(blueprint, url_prefix="/" + collection) - klass = getattr(module, collection.capitalize() + "View") - register_class(app, klass, name=collection) - logger.info(f"{collection} registered") - except AttributeError as ex: - logger.error(f"Failed to register {module_path}: {collection} {ex}") - - if getattr(app, "kernels", None): - from mpcontribs.api.notebooks.views import rq - - rq.init_app(app) - - def healthcheck(): - return jsonify({"version": app.config["VERSION"]}) - - if is_gunicorn and MPCONTRIBS_API_HOST: - app.register_blueprint(sse, url_prefix="/stream") - app.add_url_rule("/healthcheck", view_func=healthcheck) - - @app.after_request - def add_header(response): - response.headers["X-Consumer-Id"] = request.headers.get("X-Consumer-Id") - return response - - logger.info("app created.") - return app diff --git a/mpcontribs-api/mpcontribs/api/attachments/__init__.py b/mpcontribs-api/mpcontribs/api/attachments/__init__.py deleted file mode 100644 index 4d321d0bf..000000000 --- a/mpcontribs-api/mpcontribs/api/attachments/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""document and views for attachments collection""" diff --git a/mpcontribs-api/mpcontribs/api/attachments/document.py b/mpcontribs-api/mpcontribs/api/attachments/document.py deleted file mode 100644 index c5584a11a..000000000 --- a/mpcontribs-api/mpcontribs/api/attachments/document.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import boto3 -import binascii - -from hashlib import md5 -from flask import request -from base64 import b64decode, b64encode -from flask_mongoengine.documents import DynamicDocument -from mongoengine import signals, ValidationError -from mongoengine.fields import StringField -from mongoengine.queryset.manager import queryset_manager -from filetype.types.archive import Gz -from filetype.types.image import Jpeg, Png, Gif, Tiff - -from mpcontribs.api.contributions.document import get_resource, get_md5, COMPONENTS - -MAX_BYTES = 2.4 * 1024 * 1024 -BUCKET = os.environ.get("S3_ATTACHMENTS_BUCKET", "mpcontribs-attachments") -S3_ATTACHMENTS_URL = f"https://{BUCKET}.s3.amazonaws.com" -SUPPORTED_FILETYPES = (Gz, Jpeg, Png, Gif, Tiff) -SUPPORTED_MIMES = [t().mime for t in SUPPORTED_FILETYPES] - -s3_client = boto3.client("s3") - - -class Attachments(DynamicDocument): - name = StringField(required=True, help_text="file name") - md5 = StringField(regex=r"^[a-z0-9]{32}$", unique=True, help_text="md5 sum") - mime = StringField(required=True, choices=SUPPORTED_MIMES, help_text="attachment mime type") - content = StringField(required=True, help_text="base64-encoded attachment content") - meta = {"collection": "attachments", "indexes": ["name", "mime", "md5"]} - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.only("name", "md5", "mime") - - @classmethod - def post_init(cls, sender, document, **kwargs): - if document.id and document._data.get("content"): - res = get_resource("attachments") - requested_fields = res.get_requested_fields(params=request.args) - - if "content" in requested_fields: - if not document.md5: - # document.reload("md5") # TODO AttributeError: _changed_fields - raise ValueError("Please also request md5 field to retrieve attachment content!") - - retr = s3_client.get_object(Bucket=BUCKET, Key=document.md5) - document.content = b64encode(retr["Body"].read()).decode("utf-8") - - @classmethod - def pre_delete(cls, sender, document, **kwargs): - s3_client.delete_object(Bucket=BUCKET, Key=document.md5) - - @classmethod - def pre_save_post_validation(cls, sender, document, **kwargs): - if document.md5: - return # attachment already cross-referenced to existing one - - # b64 decode - try: - content = b64decode(document.content, validate=True) - except binascii.Error: - raise ValidationError(f"Attachment {document.name} not base64 encoded!") - - # check size - size = len(content) - - if size > MAX_BYTES: - raise ValidationError( - f"Attachment {document.name} too large ({size} > {MAX_BYTES})!" - ) - - # md5 - resource = get_resource("attachments") - document.md5 = get_md5(resource, document, COMPONENTS["attachments"]) - - # save to S3 and unset content - s3_client.put_object( - Bucket=BUCKET, - Key=document.md5, - ContentType=document.mime, - Metadata={"name": document.name}, - Body=content, - ) - document.content = str(size) # set to something useful to distinguish in post_init - - -signals.post_init.connect(Attachments.post_init, sender=Attachments) -signals.pre_delete.connect(Attachments.pre_delete, sender=Attachments) -signals.pre_save_post_validation.connect(Attachments.pre_save_post_validation, sender=Attachments) diff --git a/mpcontribs-api/mpcontribs/api/attachments/views.py b/mpcontribs-api/mpcontribs/api/attachments/views.py deleted file mode 100644 index ae1e8c16a..000000000 --- a/mpcontribs-api/mpcontribs/api/attachments/views.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import flask_mongorest -from flask_mongorest.resources import Resource -from flask_mongorest import operators as ops -from flask_mongorest.methods import Fetch, BulkFetch, Download -from flask import Blueprint - -from mpcontribs.api import FILTERS -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.attachments.document import Attachments - -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -attachments = Blueprint("attachments", __name__, template_folder=templates) - - -class AttachmentsResource(Resource): - document = Attachments - filters = { - "id": [ops.In, ops.Exact], - "md5": [ops.In, ops.Exact], - "name": FILTERS["STRINGS"], - "mime": FILTERS["STRINGS"], - } - fields = ["id", "name", "mime", "md5"] - allowed_ordering = ["name", "mime"] - paginate = True - default_limit = 10 - max_limit = 100 - download_formats = ["json", "csv"] - - @staticmethod - def get_optional_fields(): - return ["content"] - - -class AttachmentsView(SwaggerView): - resource = AttachmentsResource - methods = [Fetch, BulkFetch, Download] diff --git a/mpcontribs-api/mpcontribs/api/config.py b/mpcontribs-api/mpcontribs/api/config.py deleted file mode 100644 index f044f4ab9..000000000 --- a/mpcontribs-api/mpcontribs/api/config.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -"""configuration module for MPContribs Flask API""" - -import os -import json -import gzip - -from mpcontribs.api import __version__ - -formulae_path = os.path.join( - os.path.dirname(__file__), "contributions", "formulae.json.gz" -) - -with gzip.open(formulae_path) as f: - FORMULAE = json.load(f) - -VERSION = __version__ - -JSON_ADD_STATUS = False -SECRET_KEY = "super-secret" # TODO in local prod config - -MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") -MPCONTRIBS_DB = os.environ.get("MPCONTRIBS_DB_NAME", "mpcontribs") -MPCONTRIBS_MONGO_HOST = os.environ.get("MPCONTRIBS_MONGO_HOST") -MPCONTRIBS_API_HOST = os.environ.get("MPCONTRIBS_API_HOST") -MONGODB_SETTINGS = { - # Changed in version 3.9: retryWrites now defaults to True. - "host": f"mongodb+srv://{MPCONTRIBS_MONGO_HOST}/{MPCONTRIBS_DB}", - "connect": False, - "db": MPCONTRIBS_DB, - "compressors": ["snappy", "zstd", "zlib"], -} -REDIS_ADDRESS = os.environ.get("REDIS_ADDRESS", "redis") -REDIS_URL = RQ_REDIS_URL = RQ_DASHBOARD_REDIS_URL = f"redis://{REDIS_ADDRESS}" -DOC_DIR = os.path.join(os.path.dirname(__file__), f"swagger-{MPCONTRIBS_DB}") - -SWAGGER = { - "swagger_ui_bundle_js": "//unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js", - "swagger_ui_standalone_preset_js": "//unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js", - "jquery_js": "//unpkg.com/jquery@2.2.4/dist/jquery.min.js", - "swagger_ui_css": "//unpkg.com/swagger-ui-dist@3/swagger-ui.css", - "uiversion": 3, - "hide_top_bar": True, - "doc_expansion": "none", - "doc_dir": DOC_DIR, - "specs": [ - { - "endpoint": "apispec", - "route": "/apispec.json", - "rule_filter": lambda rule: True, # all in - "model_filter": lambda tag: True, # all in - } - ], - "specs_route": "/", -} - -TEMPLATE = { - "swagger": "2.0", - "info": { - "title": "MPContribs API", - "description": "Operations to contribute, update and retrieve materials data on Materials Project", - "termsOfService": "https://materialsproject.org/terms", - "version": VERSION, - "contact": { - "name": "MPContribs", - "email": "contribs@materialsproject.org", - "url": "https://mpcontribs.org", - }, - "license": { - "name": "Creative Commons Attribution 4.0 International License", - "url": "https://creativecommons.org/licenses/by/4.0/", - }, - }, - "tags": [ - { - "name": "projects", - "description": f'contain provenance information about contributed datasets. \ - Deleting projects will also delete all contributions including tables, structures, attachments, notebooks \ - and cards for the project. Only users who have been added to a project can update its contents. While \ - unpublished, only users on the project can retrieve its data or view it on the \ - Portal. Making a project public does not automatically publish all \ - its contributions, tables, attachments, and structures. These are separately set to public individually or in bulk.' - "", - }, - { - "name": "contributions", - "description": f'contain simple hierarchical data which will show up as cards on the MP details \ - page for MP material(s). Tables (rows and columns), structures, and attachments can be added to a \ - contribution. Each contribution uses `mp-id` or composition as identifier to associate its data with the \ - according entries on MP. Only admins or users on the project can create, update or delete contributions, and \ - while unpublished, retrieve its data or view it on the Portal. \ - Contribution components (tables, structures, and attachments) are deleted along with a contribution.', - }, - { - "name": "structures", - "description": 'are \ - pymatgen structures which \ - can be added to a contribution.', - }, - { - "name": "tables", - "description": 'are simple spreadsheet-type tables with columns and rows saved as Pandas \ - DataFrames \ - which can be added to a contribution.', - }, - { - "name": "attachments", - "description": 'are files saved as objects in AWS S3 and not accessible for querying (only retrieval) \ - which can be added to a contribution.', - }, - { - "name": "notebooks", - "description": f'are Jupyter \ - notebook \ - documents generated and saved when a contribution is saved. They form the basis for Contribution \ - Details Pages on the Portal.', - }, - ], - "securityDefinitions": { - "ApiKeyAuth": { - "description": "MP API key to authorize requests", - "name": "X-API-KEY", - "in": "header", - "type": "apiKey", - } - }, - "security": [{"ApiKeyAuth": []}], -} diff --git a/mpcontribs-api/mpcontribs/api/contributions/document.py b/mpcontribs-api/mpcontribs/api/contributions/document.py deleted file mode 100644 index db434cdf8..000000000 --- a/mpcontribs-api/mpcontribs/api/contributions/document.py +++ /dev/null @@ -1,333 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import itertools - -from hashlib import md5 -from math import isnan -from bson.dbref import DBRef -from datetime import datetime -from flask import current_app -from atlasq import AtlasManager, AtlasQ -from itertools import permutations -from importlib import import_module -from fastnumbers import isfloat -from mongoengine import CASCADE, signals, DynamicDocument -from mongoengine.queryset.manager import queryset_manager -from mongoengine.fields import StringField, BooleanField, DictField -from mongoengine.fields import LazyReferenceField, ReferenceField -from mongoengine.fields import DateTimeField, ListField -from boltons.iterutils import remap -from decimal import Decimal -from pint import UnitRegistry -from pint.errors import DimensionalityError -from uncertainties import ufloat_fromstr -from pymatgen.core import Composition, Element - -from mpcontribs.api import enter, valid_dict, delimiter - -quantity_keys = {"display", "value", "error", "unit"} -max_dgts = 6 -ureg = UnitRegistry( - autoconvert_offset_to_baseunit=True, - preprocessors=[ - lambda s: s.replace("%%", " permille "), - lambda s: s.replace("%", " percent "), - ], -) -ureg.formatter.default_format = "~,P" - -if "percent" not in ureg: - # percent is native in pint >= 0.21 - ureg.define("percent = 0.01 = %") -if "permille" not in ureg: - # permille is native in pint >= 0.24.2 - ureg.define("permille = 0.001 = ‰ = %%") -if "ppm" not in ureg: - # ppm is native in pint >= 0.21 - ureg.define("ppm = 1e-6") -ureg.define("ppb = 1e-9") -ureg.define("atom = 1") -ureg.define("bohr_magneton = e * hbar / (2 * m_e) = µᵇ = µ_B = mu_B") -ureg.define("electron_mass = 9.1093837015e-31 kg = mₑ = m_e") -ureg.define("sccm = cm³/min") - -COMPONENTS = { - "structures": ["lattice", "sites", "charge"], - "tables": ["index", "columns", "data"], - "attachments": ["mime", "content"], -} - - -def grouper(n, iterable): - it = iter(iterable) - while True: - chunk = tuple(itertools.islice(it, n)) - if not chunk: - return - yield chunk - - -def format_cell(cell): - cell = cell.strip() - if not cell or cell.count(" ") > 1: - return cell - - q = get_quantity(cell) - if not q or isnan(q.magnitude.nominal_value): - return cell - - q = truncate_digits(q) - try: - return str(q.magnitude.nominal_value) if isnan(q.magnitude.std_dev) else str(q) - except Exception: - return cell - - -def new_error_units(measurement, quantity): - if quantity.units == measurement.value.units: - return measurement - - error = measurement.error.to(quantity.units) - return ureg.Measurement(quantity, error) - - -def get_quantity(s): - # 5, 5 eV, 5+/-1 eV, 5(1) eV - # set uncertainty to nan if not provided - parts = s.split() - parts += [None] * (2 - len(parts)) - if isfloat(parts[0]): - parts[0] += "+/-nan" - - try: - parts[0] = ufloat_fromstr(parts[0]) - return ureg.Measurement(*parts) - except ValueError: - return None - - -def truncate_digits(q): - if isnan(q.magnitude.nominal_value): - return q - - v = Decimal(str(q.magnitude.nominal_value)) - vt = v.as_tuple() - - if vt.exponent >= 0: - return q - - dgts = len(vt.digits) - dgts = max_dgts if dgts > max_dgts else dgts - s = f"{v:.{dgts}g}" - if not isnan(q.magnitude.std_dev): - s += f"+/-{q.magnitude.std_dev:.{dgts}g}" - - if q.units: - s += f" {q.units}" - - return get_quantity(s) - - -def get_resource(component): - klass = component.capitalize() - vmodule = import_module(f"mpcontribs.api.{component}.views") - Resource = getattr(vmodule, f"{klass}Resource") - return Resource() - - -def get_md5(resource, obj, fields): - d = resource.serialize(obj, fields=fields) - s = json.dumps(d, sort_keys=True).encode("utf-8") - return md5(s).hexdigest() - - -class Contributions(DynamicDocument): - project = LazyReferenceField( - "Projects", required=True, passthrough=True, reverse_delete_rule=CASCADE - ) - identifier = StringField(required=True, help_text="material/composition identifier") - formula = StringField(help_text="formula (set dynamically if not provided)") - is_public = BooleanField( - required=True, default=True, help_text="public/private contribution" - ) - last_modified = DateTimeField( - required=True, default=datetime.utcnow, help_text="time of last modification" - ) - needs_build = BooleanField(default=True, help_text="needs notebook build?") - data = DictField( - default=dict, - validation=valid_dict, - pullout_key="display", - help_text="simple free-form data", - ) - structures = ListField( - ReferenceField("Structures", null=True), default=list, max_length=10 - ) - tables = ListField(ReferenceField("Tables", null=True), default=list, max_length=10) - attachments = ListField( - ReferenceField("Attachments", null=True), default=list, max_length=10 - ) - notebook = ReferenceField("Notebooks") - atlas = AtlasManager("formula_autocomplete") - meta = { - "collection": "contributions", - "indexes": [ - "project", - "identifier", - "formula", - "is_public", - "last_modified", - "needs_build", - "notebook", - {"fields": [(r"data.$**", 1)]}, - # can only use wildcardProjection option with wildcard index on all document fields - {"fields": [(r"$**", 1)], "wildcardProjection": {"project": 1}}, - ] - + list(COMPONENTS.keys()), - } - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.no_dereference().only( - "project", - "identifier", - "formula", - "is_public", - "last_modified", - "needs_build", - ) - - @classmethod - def atlas_filter(cls, term): - try: - comp = Composition(term) - except Exception: - raise ValueError(f"{term} is not a valid composition") - - try: - for element in comp.elements: - Element(element) - except Exception: - raise ValueError(f"{element} not a valid element") - - ind_str = [] - - if len(comp) == 1: - d = comp.get_integer_formula_and_factor() - ind_str.append(d[0] + str(int(d[1])) if d[1] != 1 else d[0]) - else: - for i, j in comp.reduced_composition.items(): - ind_str.append(i.name + str(int(j)) if j != 1 else i.name) - - final_terms = ["".join(entry) for entry in permutations(ind_str)] - return AtlasQ(formula=final_terms[0]) # TODO formula__in=final_terms - - @classmethod - def post_init(cls, sender, document, **kwargs): - # replace existing components with according ObjectIds - for component, fields in COMPONENTS.items(): - lst = document._data.get(component) - if lst and lst[0].id is None: # id is None for incoming POST - resource = get_resource(component) - for i, o in enumerate(lst): - digest = get_md5(resource, o, fields) - objs = resource.document.objects(md5=digest) - exclude = list(resource.document._fields.keys()) - obj = objs.exclude(*exclude).only("id").first() - if obj: - lst[i] = obj.to_dbref() - - @classmethod - def pre_save_post_validation(cls, sender, document, **kwargs): - # set formula field - if hasattr(document, "formula") and not document.formula: - formulae = current_app.config["FORMULAE"] - document.formula = formulae.get(document.identifier, document.identifier) - - # project is LazyReferenceField & load columns due to custom queryset manager - project = document.project.fetch().reload("columns") - columns = {col.path: col for col in project.columns} - - # run data through Pint Quantities and save as dicts - def make_quantities(path, key, value): - key = key.strip() - if key in quantity_keys or not isinstance(value, (str, int, float)): - return key, value - - # can't be a quantity if contains 2+ spaces - str_value = str(value).strip() - if str_value.count(" ") > 1: - return key, value - - # don't parse if column.unit indicates string type - field = delimiter.join(["data"] + list(path) + [key]) - if field in columns: - if columns[field].unit == "NaN": - return key, str_value - - # parse as quantity - q = get_quantity(str_value) - if q is None or not q._magnitude: - return key, value - - # silently ignore "nan" - if isnan(q.magnitude.nominal_value): - return False - - # ensure that the same units are used across contributions - if field in columns: - column = columns[field] - if column.unit != str(q.value.units): - try: - qq = q.value.to(column.unit) - q = new_error_units(q, qq) - except DimensionalityError: - raise ValueError( - f"Can't convert [{q.units}] to [{column.unit}] for {field}!" - ) - else: - # try compact representation - qq = q.value.to_compact() - q = new_error_units(q, qq) - - # reduce dimensionality if possible - if not q.check(0): - qq = q.value.to_reduced_units() - q = new_error_units(q, qq) - - # significant digits - q = truncate_digits(q) - - # return new value dict - display = str(q.value) if isnan(q.magnitude.std_dev) else str(q) - value = { - "display": display, - "value": q.magnitude.nominal_value, - "error": q.magnitude.std_dev, - "unit": str(q.units), - } - return key, value - - document.data = remap(document.data, visit=make_quantities, enter=enter) - document.last_modified = datetime.utcnow() - document.needs_build = True - - @classmethod - def pre_delete(cls, sender, document, **kwargs): - args = list(COMPONENTS.keys()) - document.reload(*args) - - for component in COMPONENTS.keys(): - # check if other contributions exist before deletion - # and make sure component still exists (getattr converts ref to object) - for obj in getattr(document, component): - q = {component: obj.id} - if sender.objects(**q).count() < 2 and not isinstance(obj, DBRef): - obj.delete() - - -signals.post_init.connect(Contributions.post_init, sender=Contributions) -signals.pre_save_post_validation.connect( - Contributions.pre_save_post_validation, sender=Contributions -) -signals.pre_delete.connect(Contributions.pre_delete, sender=Contributions) diff --git a/mpcontribs-api/mpcontribs/api/contributions/formulae.json.gz b/mpcontribs-api/mpcontribs/api/contributions/formulae.json.gz deleted file mode 100644 index 5f6945aaa..000000000 --- a/mpcontribs-api/mpcontribs/api/contributions/formulae.json.gz +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:358326e2613ac16500283f85c3cc3780567a568cc3efa5431bde7e50f1978135 -size 5418887 diff --git a/mpcontribs-api/mpcontribs/api/contributions/generate_formulae.py b/mpcontribs-api/mpcontribs/api/contributions/generate_formulae.py deleted file mode 100644 index f625ff27b..000000000 --- a/mpcontribs-api/mpcontribs/api/contributions/generate_formulae.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import json -from pymatgen.ext.matproj import MPRester - -data = {} - -with MPRester() as mpr: - for i, d in enumerate( - mpr.query(criteria={}, properties=["task_ids", "pretty_formula"]) - ): - for task_id in d["task_ids"]: - data[task_id] = d["pretty_formula"] - -out = os.path.join(os.path.dirname(__file__), "formulae.json") -with open(out, "w") as f: - json.dump(data, f) diff --git a/mpcontribs-api/mpcontribs/api/contributions/views.py b/mpcontribs-api/mpcontribs/api/contributions/views.py deleted file mode 100644 index 86bfb70b8..000000000 --- a/mpcontribs-api/mpcontribs/api/contributions/views.py +++ /dev/null @@ -1,227 +0,0 @@ -# -*- coding: utf-8 -*- -import re -import os -import flask_mongorest - -from itertools import permutations -from css_html_js_minify import html_minify -from json2html import Json2Html -from boltons.iterutils import remap -from werkzeug.exceptions import Unauthorized -from pymatgen.core.composition import Composition, CompositionError - -from flask import Blueprint, render_template, jsonify, abort, request -from flask_mongorest.resources import Resource -from flask_mongorest import operators as ops -from flask_mongorest.methods import ( - Fetch, - Delete, - Update, - BulkFetch, - BulkCreate, - BulkUpdate, - BulkDelete, - Download, -) -from flask_mongorest.exceptions import UnknownFieldError - -from mpcontribs.api import enter, FILTERS -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.contributions.document import Contributions -from mpcontribs.api.structures.views import StructuresResource -from mpcontribs.api.tables.views import TablesResource -from mpcontribs.api.attachments.views import AttachmentsResource -from mpcontribs.api.notebooks.views import NotebooksResource - -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -contributions = Blueprint("contributions", __name__, template_folder=templates) -exclude = r'[^$.\s_~`^&(){}[\]\\;\'"/]' -j2h = Json2Html() -MAX_UNAPPROVED_CONTRIBS = 500 - - -def visit(path, key, value): - if isinstance(value, dict) and "display" in value: - return key, value["display"] - return True - - -class ContributionsResource(Resource): - document = Contributions - related_resources = { - "structures": StructuresResource, - "tables": TablesResource, - "attachments": AttachmentsResource, - "notebook": NotebooksResource, - } - save_related_fields = ["structures", "tables", "attachments", "notebook"] - filters = { - "id": [ops.In, ops.Exact], - "project": FILTERS["STRINGS"], - "identifier": FILTERS["STRINGS"], - "formula": FILTERS["STRINGS"], - "is_public": [ops.Boolean], - "last_modified": FILTERS["DATES"], - "needs_build": [ops.Boolean], - re.compile(r"^data__((?!__).)*$"): FILTERS["ALL"], - "structures": [ops.Size], - "tables": [ops.Size], - "attachments": [ops.Size], - } - fields = [ - "id", - "project", - "identifier", - "formula", - "is_public", - "last_modified", - "needs_build", - ] - allowed_ordering = [ - "id", - "project", - "identifier", - "formula", - "is_public", - "last_modified", - "needs_build", - re.compile(r"^data(__(" + exclude + ")+){1,4}$"), - ] - paginate = True - default_limit = 200 - max_limit = 1500 - download_formats = ["json", "csv"] - - @staticmethod - def get_optional_fields(): - return [ - "data", - "structures", - "tables", - "attachments", - "notebook", - "card_bootstrap", - "card_bulma", - ] - - def value_for_field(self, obj, field): - if field.startswith("card_"): - _, fmt = field.rsplit("_", 1) - if fmt not in ["bootstrap", "bulma"]: - raise UnknownFieldError - - if obj.project is None or not obj.data: - # try data reload to account for custom queryset manager - obj.reload("id", "project", "data") - - # obj.project is LazyReference & Projects uses custom queryset manager - DocType = obj.project.document_type - exclude = list(DocType._fields.keys()) - only = ["title", "references", "description", "authors"] - pk = obj.project.pk - project = DocType.objects.exclude(*exclude).only(*only).with_id(pk) - ctx = { - "cid": str(obj.id), - "title": project.title, - "references": project.references[:5], - "landing_page": f"/projects/{project.id}/", - "more": f"/contributions/{obj.id}", - } - ctx["descriptions"] = project.description.strip().split(".", 1) - authors = [a.strip() for a in project.authors.split(",") if a] - ctx["authors"] = {"main": authors[0], "etal": authors[1:]} - ctx["data"] = j2h.convert( - json=remap(obj.data, visit=visit, enter=enter), - table_attributes='class="table is-narrow is-fullwidth has-background-light"', - ) - return html_minify(render_template(f"card_{fmt}.html", **ctx)) - else: - raise UnknownFieldError - - -class ContributionsView(SwaggerView): - resource = ContributionsResource - methods = [ - Fetch, - Delete, - Update, - BulkFetch, - BulkCreate, - BulkUpdate, - BulkDelete, - Download, - ] - - def has_add_permission(self, req, obj): - # limit the number of contributions for unapproved projects - if not self.is_admin_or_project_user(req, obj): - return False - - if not obj.project.is_approved: - nr_contribs = Contributions.objects(project=obj.project.id).count() - if nr_contribs > MAX_UNAPPROVED_CONTRIBS: - msg = f"Reached {MAX_UNAPPROVED_CONTRIBS} for unapproved project {obj.project.id}." - msg += " Please reach out to contribs@materialsproject.org." - raise Unauthorized(f"Can't add {obj.identifier}: {msg}") - - query = dict(project=obj.project.id, identifier=obj.identifier) - if obj.project.unique_identifiers and Contributions.objects(**query).count(): - raise Unauthorized(f"{obj.identifier} already added for {obj.project.id}") - - return True - - -@contributions.route("/search") -def search(): - formula = request.args.get("formula") - if not formula: - abort(404, description="Missing formula param.") - - try: - comp = Composition(formula) - except (CompositionError, ValueError): - abort(400, description="Invalid formula provided.") - - ind_str = [] - - if len(comp) == 1: - d = comp.get_integer_formula_and_factor() - ind_str.append(d[0] + str(int(d[1])) if d[1] != 1 else d[0]) - else: - for i, j in comp.reduced_composition.items(): - ind_str.append(i.name + str(int(j)) if j != 1 else i.name) - - final_terms = ["".join(entry) for entry in permutations(ind_str)] - limit = request.args.get("limit", ContributionsResource.default_limit) - - pipeline = [ - { - "$search": { - "index": "formula_autocomplete", - "text": {"path": "formula", "query": final_terms}, - } - }, - {"$project": {"formula": 1, "length": {"$strLenCP": "$formula"}, "project": 1}}, - {"$match": {"length": {"$gte": len(final_terms[0])}}}, - {"$limit": limit}, - {"$sort": {"length": 1}}, - ] - - results = [] - - try: - for contrib in Contributions.objects().aggregate(pipeline, maxTimeMS=15000): - results.append( - { - "id": str(contrib["_id"]), - "formula": contrib["formula"], - "project": contrib["project"], - } - ) - except Exception: - abort( - 500, - description="Can't complete search. Please try a different formula or try again later.", - ) - - return jsonify(results) diff --git a/mpcontribs-api/mpcontribs/api/core.py b/mpcontribs-api/mpcontribs/api/core.py deleted file mode 100644 index ff24418d6..000000000 --- a/mpcontribs-api/mpcontribs/api/core.py +++ /dev/null @@ -1,644 +0,0 @@ -# -*- coding: utf-8 -*- -"""Custom meta-class and MethodView for Swagger""" - -import os -import logging -import yaml - -from copy import deepcopy -from re import Pattern -from importlib import import_module -from flasgger.marshmallow_apispec import SwaggerView as OriginalSwaggerView -from flasgger.marshmallow_apispec import schema2jsonschema -from marshmallow_mongoengine import ModelSchema -from flask_mongorest.views import ResourceView -from mongoengine.queryset import DoesNotExist -from mongoengine.queryset.visitor import Q -from werkzeug.exceptions import Unauthorized -from mpcontribs.api.config import DOC_DIR -from mpcontribs.api import is_gunicorn, get_logger - -logger = get_logger(__name__) - - -def get_limit_params(resource, method): - default = resource.default_limit - bulk = {"BulkUpdate", "BulkDelete"} - maximum = resource.bulk_update_limit if method in bulk else resource.max_limit - return [ - { - "name": "_skip", - "in": "query", - "type": "integer", - "description": "number of items to skip", - }, - { - "name": "_limit", - "in": "query", - "type": "integer", - "default": default, - "maximum": maximum, - "description": "maximum number of items to return", - }, - { - "name": "page", - "in": "query", - "type": "integer", - "description": "page number to return (in batches of `per_page/_limit`; alternative to `_skip`)", - }, - { - "name": "per_page", - "in": "query", - "type": "integer", - "default": default, - "maximum": maximum, - "description": "maximum number of items to return per page (same as `_limit`)", - }, - ] - - -def get_filter_params(name, filters): - filter_params = [] - is_pattern = isinstance(name, Pattern) - label = name.pattern if is_pattern else name - for op in filters: - if op.op == "exact" and not is_pattern: - name = label - description = f"filter {label}" - else: - suffix = op.suf if hasattr(op, "suf") else op.op - name = f"{label}__{suffix}" - description = f"filter {label} via ${op.op}" - - filter_params.append( - { - "name": name, - "in": "query", - "type": op.typ, - "description": description, - } - ) - if op.typ == "array": - filter_params[-1]["items"] = {"type": "string"} - if hasattr(op, "fmt"): - filter_params[-1]["format"] = op.fmt - - if op.allow_negation: - suffix = "not__" - suffix += op.suf if hasattr(op, "suf") else op.op - name = f"{label}__{suffix}" - description = f"filter {label} via ${op.op}" - param = deepcopy(filter_params[-1]) - param["name"] = name - param["description"] = description - filter_params.append(param) - - return filter_params - - -def get_specs(klass, method, collection): - method_name = method.__name__ if hasattr(method, "__name__") else method - default_response = { - "description": "Error", - "schema": {"type": "object", "properties": {"error": {"type": "string"}}}, - } - id_field = klass.resource.document._meta["id_field"].capitalize() - doc_name = collection[:-1].capitalize() - fields_param = None - if klass.resource.fields is not None: - fields_avail = ( - klass.resource.fields + klass.resource.get_optional_fields() + ["_all"] - ) - description = f"List of fields to include in response ({fields_avail})." - description += " Use dot-notation for nested subfields." - fields_param = { - "name": "_fields", - "in": "query", - "default": klass.resource.fields, - "type": "array", - "items": {"type": "string"}, - "description": description, - } - - field_pagination_params = [] - for field, limits in klass.resource.fields_to_paginate.items(): - field_pagination_params.append( - { - "name": f"{field}_page", - "in": "query", - "default": 1, - "type": "integer", - "description": f"page to retrieve for {field} field", - } - ) - field_pagination_params.append( - { - "name": f"{field}_per_page", - "in": "query", - "default": limits[0], - "maximum": limits[1], - "type": "integer", - "description": f"number of items to retrieve per page for {field} field", - } - ) - - filter_params = [] - if hasattr(klass.resource, "filters"): - for k, v in klass.resource.filters.items(): - filter_params += get_filter_params(k, v) - - order_params = [] - if klass.resource.allowed_ordering: - allowed_ordering = [ - o.pattern if isinstance(o, Pattern) else o - for o in klass.resource.allowed_ordering - ] - order_params = [ - { - "name": "_sort", - "in": "query", - "type": "string", - "description": f"sort {collection} via {allowed_ordering}. Prepend +/- for asc/desc.", - } - ] - - spec = None - if method_name == "Fetch": - params = [ - { - "name": "pk", - "in": "path", - "type": "string", - "required": True, - "description": f"{collection[:-1]} (primary key)", - } - ] - if fields_param is not None: - params.append(fields_param) - params += field_pagination_params - spec = { - "summary": f"Retrieve a {collection[:-1]}.", - "operationId": f"get{doc_name}By{id_field}", - "parameters": params, - "responses": { - 200: { - "description": f"single {collection} entry", - "schema": {"$ref": f"#/definitions/{klass.schema_name}"}, - }, - "default": default_response, - }, - } - - elif method_name == "BulkFetch": - params = [fields_param] if fields_param is not None else [] - params += field_pagination_params - params += order_params - params += filter_params - schema_props = { - "data": { - "type": "array", - "items": {"$ref": f"#/definitions/{klass.schema_name}"}, - } - } - if klass.resource.paginate: - schema_props["has_more"] = {"type": "boolean"} - schema_props["total_count"] = {"type": "integer"} - schema_props["total_pages"] = {"type": "integer"} - params += get_limit_params(klass.resource, method_name) - spec = { - "summary": f"Filter and retrieve {collection}.", - "operationId": f"query{doc_name}s", - "parameters": params, - "responses": { - 200: { - "description": f"list of {collection}", - "schema": {"type": "object", "properties": schema_props}, - }, - "default": default_response, - }, - } - - elif method_name == "Download": - params = [ - { - "name": "short_mime", - "in": "path", - "type": "string", - "required": True, - "description": "MIME Download Type: gz", - "default": "gz", - }, - { - "name": "format", - "in": "query", - "type": "string", - "required": True, - "description": f"download {collection} in different formats: {klass.resource.download_formats}", - }, - ] - params += [fields_param] if fields_param is not None else [] - params += order_params - params += filter_params - if klass.resource.paginate: - params += get_limit_params(klass.resource, method_name) - spec = { - "summary": f"Filter and download {collection}.", - "operationId": f"download{doc_name}s", - "parameters": params, - "produces": ["application/gzip"], - "responses": { - 200: { - "description": f"{collection} download", - "schema": {"type": "file"}, - }, - "default": default_response, - }, - } - - elif method_name == "Create": - spec = { - "summary": f"Create a new {collection[:-1]}.", - "operationId": f"create{doc_name}", - "parameters": [ - { - "name": f"{collection[:-1]}", - "in": "body", - "description": f"The object to use for {collection[:-1]} creation", - "schema": {"$ref": f"#/definitions/{klass.schema_name}"}, - } - ], - "responses": { - 200: { - "description": f"{collection[:-1]} created", - "schema": {"$ref": f"#/definitions/{klass.schema_name}"}, - }, - "default": default_response, - }, - } - - elif method_name == "BulkCreate": - spec = { - "summary": f"Create new {collection[:-1]}(s).", - "operationId": f"create{doc_name}s", - "parameters": [ - { - "name": f"{collection}", - "in": "body", - "description": f"The objects to use for {collection[:-1]} creation", - "schema": { - "type": "array", - "items": {"$ref": f"#/definitions/{klass.schema_name}"}, - }, - } - ], - "responses": { - 200: { - "description": f"{collection} created", - "schema": { - "type": "object", - "properties": { - "count": {"type": "integer"}, - "data": { - "type": "array", - "items": {"$ref": f"#/definitions/{klass.schema_name}"}, - }, - }, - }, - }, - "default": default_response, - }, - } - - elif method_name == "Update": - spec = { - "summary": f"Update a {collection[:-1]}.", - "operationId": f"update{doc_name}By{id_field}", - "parameters": [ - { - "name": "pk", - "in": "path", - "type": "string", - "required": True, - "description": f"The {collection[:-1]} (primary key) to update", - }, - { - "name": f"{collection[:-1]}", - "in": "body", - "description": f"The object to use for {collection[:-1]} update", - "schema": {"type": "object"}, - }, - ], - "responses": { - 200: { - "description": f"{collection[:-1]} updated", - "schema": {"$ref": f"#/definitions/{klass.schema_name}"}, - }, - "default": default_response, - }, - } - elif method_name == "BulkUpdate": - params = filter_params - params.append( - { - "name": f"{collection}", - "in": "body", - "description": f"The object to use for {collection} bulk update", - "schema": {"type": "object"}, - } - ) - schema_props = {"count": {"type": "integer"}} - if klass.resource.paginate: - schema_props["has_more"] = {"type": "boolean"} - schema_props["total_count"] = {"type": "integer"} - schema_props["total_pages"] = {"type": "integer"} - params += get_limit_params(klass.resource, method_name) - spec = { - "summary": f"Filter and update {collection}.", - "operationId": f"update{doc_name}s", - "parameters": params, - "responses": { - 200: { - "description": f"Number of {collection} updated", - "schema": {"type": "object", "properties": schema_props}, - }, - "default": default_response, - }, - } - - elif method_name == "BulkDelete": - params = filter_params - schema_props = {"count": {"type": "integer"}} - if klass.resource.paginate: - schema_props["has_more"] = {"type": "boolean"} - schema_props["total_count"] = {"type": "integer"} - schema_props["total_pages"] = {"type": "integer"} - params += get_limit_params(klass.resource, method_name) - spec = { - "summary": f"Filter and delete {collection}.", - "operationId": f"delete{doc_name}s", - "parameters": params, - "responses": { - 200: { - "description": f"Number of {collection} deleted", - "schema": {"type": "object", "properties": schema_props}, - }, - "default": default_response, - }, - } - - elif method_name == "Delete": - spec = { - "summary": f"Delete a {collection[:-1]}.", - "operationId": f"delete{doc_name}By{id_field}", - "parameters": [ - { - "name": "pk", - "in": "path", - "type": "string", - "required": True, - "description": f"The {collection[:-1]} (primary key) to delete", - } - ], - "responses": { - 200: {"description": f"{collection[:-1]} deleted"}, - "default": default_response, - }, - } - - return spec - - -class SwaggerView(OriginalSwaggerView, ResourceView): - """A class-based view defining additional methods""" - - def __init_subclass__(cls, **kwargs): - """initialize Schema, decorators, definitions, and tags""" - super().__init_subclass__(**kwargs) - - if not __name__ == cls.__module__: - # e.g.: cls.__module__ = mpcontribs.api.projects.views - views_path = cls.__module__.split(".") - doc_path = ".".join(views_path[:-1] + ["document"]) - cls.tags = [views_path[-2]] - doc_filepath = doc_path.replace(".", os.sep) + ".py" - if os.path.exists(doc_filepath): - cls.doc_name = cls.tags[0].capitalize() - Model = getattr(import_module(doc_path), cls.doc_name) - cls.schema_name = cls.doc_name + "Schema" - cls.Schema = type( - cls.schema_name, - (ModelSchema, object), - { - "Meta": type( - "Meta", - (object,), - dict(model=Model, ordered=True, model_build_obj=False), - ) - }, - ) - cls.definitions = {cls.schema_name: schema2jsonschema(cls.Schema)} - cls.resource.schema = cls.Schema - - # write flask-mongorest swagger specs - for method in cls.methods: - spec = get_specs(cls, method, cls.tags[0]) - if spec: - dir_path = os.path.join(DOC_DIR, cls.tags[0]) - file_path = os.path.join(dir_path, method.__name__ + ".yml") - if not os.path.exists(file_path): - os.makedirs(dir_path, exist_ok=True) - - if is_gunicorn: - with open(file_path, "w") as f: - yaml.dump(spec, f) - logger.debug( - f"{cls.tags[0]}.{method.__name__} written to {file_path}" - ) - - def get_groups(self, request): - groups = request.headers.get("X-Authenticated-Groups", "").split(",") - groups += request.headers.get("X-Consumer-Groups", "").split(",") - return set(grp.strip() for grp in groups if grp) - - def is_anonymous(self, request): - if not request.headers.get("X-Consumer-Username", ""): - return True - - is_anonymous = request.headers.get("X-Anonymous-Consumer", False) - if isinstance(is_anonymous, str): - is_anonymous = False if is_anonymous == "false" else True - - return is_anonymous - - def is_external(self, request): - return request.headers.get( - "X-Forwarded-Host" - ) is not None and not request.headers.get("Origin") - - def is_admin(self, request): - groups = self.get_groups(request) - admin_group = os.environ.get("ADMIN_GROUP", "admin") - return admin_group in groups - - def is_project_user(self, request, obj): - if hasattr(obj, "owner"): - owner = obj.owner - project = obj.name - elif hasattr(obj, "project"): - owner = obj.project.owner - project = obj.project.name - else: - raise Unauthorized(f"Unable to authorize {obj}") - - groups = self.get_groups(request) - username = request.headers.get("X-Consumer-Username") - return project in groups or owner == username - - def is_admin_or_project_user(self, request, obj): - if self.is_anonymous(request): - return False - - if self.is_admin(request): - return True - - return self.is_project_user(request, obj) - - def get_projects(self): - # project is LazyReferenceFields (multiple queries) - module = import_module("mpcontribs.api.projects.document") - Projects = getattr(module, "Projects") - exclude = list(Projects._fields.keys()) - only = ["name", "owner", "is_public", "is_approved"] - return Projects.objects.exclude(*exclude).only(*only) - - def get_projects_filter(self, username, groups, filter_names=None): - projects = self.get_projects() - if filter_names: - projects = projects.filter(name__in=filter_names) - - q = {"private": [], "public": []} - - for project in projects: - if project.owner == username or project.name in groups: - q["private"].append(project.name) - elif project.is_public and project.is_approved: - q["public"].append(project.name) - - # reduced query - qfilter = Q() - if q["private"]: - qfilter |= Q(project__in=q["private"]) - if q["public"]: - qfilter |= Q(project__in=q["public"], is_public=True) - - return qfilter - - def has_read_permission(self, request, qs): - if self.is_admin(request): - return qs # admins can read all entries - - groups = self.get_groups(request) - is_anonymous = self.is_anonymous(request) - is_external = self.is_external(request) - username = request.headers.get("X-Consumer-Username") - approved_public_filter = Q(is_public=True, is_approved=True) - - if request.path.startswith("/projects/"): - # external or internal requests can both read full project info - # anonymous requests can only read public approved projects - if is_anonymous: - return qs.filter(approved_public_filter) - - # authenticated requests can read approved public or accessible non-public projects - qfilter = approved_public_filter | Q(owner=username) - if groups: - qfilter |= Q(name__in=list(groups)) - - return qs.filter(qfilter) - else: - # contributions are set private/public independent from projects - # anonymous requests: - # - external: only meta-data of public contributions in approved public projects - # - internal: full public contributions in approved public projects - # authenticated requests: - # - private contributions in a public project are only accessible to owner/group - # - any contributions in a private project are only accessible to owner/group - component = request.path.split("/")[1] - - if component == "contributions": - q = qs._query - if is_anonymous and is_external: - qs = qs.exclude("data") - - if q and "project" in q and isinstance(q["project"], str): - projects = self.get_projects() - try: - project = projects.get(name=q["project"]) - except DoesNotExist: - return qs.none() - - if project.owner == username or project.name in groups: - return qs - elif project.is_public and project.is_approved: - return qs.filter(is_public=True) - else: - return qs.none() - else: - names = None - if q and "project" in q and "$in" in q["project"]: - names = q.pop("project").pop("$in") - - qfilter = self.get_projects_filter( - username, groups, filter_names=names - ) - return qs.filter(qfilter) - else: - # get component Object IDs for queryset - pk = request.view_args.get("pk") - from mpcontribs.api.contributions.document import get_resource - - resource = get_resource(component) - qfilter = lambda qs: qs.clone() - - if pk: - ids = [resource.get_object(pk, qfilter=qfilter).id] - else: - ids = [o.id for o in resource.get_objects(qfilter=qfilter)[0]] - - if not ids: - return qs.none() - - # get list of readable contributions and their component Object IDs - module = import_module("mpcontribs.api.contributions.document") - Contributions = getattr(module, "Contributions") - qfilter = self.get_projects_filter(username, groups) - component = component[:-1] if component == "notebooks" else component - qfilter &= Q(**{f"{component}__in": ids}) - contribs = Contributions.objects(qfilter).only(component).limit(len(ids)) - # return new queryset using "ids__in" - readable_ids = [ - getattr(contrib, component).id for contrib in contribs - ] if component == "notebook" else [ - dbref.id for contrib in contribs - for dbref in getattr(contrib, component) - if dbref.id in ids - ] - if not readable_ids: - return qs.none() - - qs._query_obj = Q(id__in=readable_ids) - # exclude optional fields if anonymous external request - if is_anonymous and is_external: - exclude = resource.get_optional_fields() - qs = qs.exclude(*exclude) - - return qs - - def has_add_permission(self, request, obj): - return self.is_admin_or_project_user(request, obj) - - def has_change_permission(self, request, obj): - return self.is_admin_or_project_user(request, obj) - - def has_delete_permission(self, request, obj): - return self.is_admin_or_project_user(request, obj) diff --git a/mpcontribs-api/mpcontribs/api/dashboard.cfg b/mpcontribs-api/mpcontribs/api/dashboard.cfg deleted file mode 100644 index ef84c4716..000000000 --- a/mpcontribs-api/mpcontribs/api/dashboard.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[dashboard] -SAMPLING_PERIOD=20 -MONITOR_LEVEL=3 -ENABLE_LOGGING=True - -[visualization] -TIMEZONE=America/New_York diff --git a/mpcontribs-api/mpcontribs/api/notebooks/__init__.py b/mpcontribs-api/mpcontribs/api/notebooks/__init__.py deleted file mode 100644 index 2db2955a6..000000000 --- a/mpcontribs-api/mpcontribs/api/notebooks/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- -from uuid import uuid1 -from flask import current_app -from tornado.escape import json_encode, json_decode -from mpcontribs.api import create_kernel_connection, get_logger - -logger = get_logger(__name__) - - -def run_cells(kernel_id, cid, cells): - logger.debug(f"running {cid} on {kernel_id}") - ws = create_kernel_connection(kernel_id) - outputs = {} - - for idx, cell in enumerate(cells): - if cell["cell_type"] == "code": - ws.send( - json_encode( - { - "header": { - "username": cid, - "version": "5.3", - "session": "", - "msg_id": f"{cid}-{idx}-{uuid1()}", - "msg_type": "execute_request", - }, - "parent_header": {}, - "channel": "shell", - "content": { - "code": cell["source"], - "silent": False, - "store_history": False, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "metadata": {}, - "buffers": [], - } - ) - ) - - outputs[idx] = [] - status = None - while status is None or status == "busy" or not len(outputs[idx]): - msg = ws.recv() - msg = json_decode(msg) - msg_type = msg["msg_type"] - if msg_type == "status": - status = msg["content"]["execution_state"] - elif msg_type in ["stream", "display_data", "execute_result"]: - # display_data/execute_result required fields: - # "output_type", "data", "metadata" - # stream required fields: "output_type", "name", "text" - output = msg["content"] - output.pop("transient", None) - output["output_type"] = msg_type - msg_idx = msg["parent_header"]["msg_id"].split("-")[1] - outputs[int(msg_idx)].append(output) - elif msg_type == "error": - tb = msg["content"]["traceback"] - raise ValueError(tb) - - ws.close() - return outputs diff --git a/mpcontribs-api/mpcontribs/api/notebooks/document.py b/mpcontribs-api/mpcontribs/api/notebooks/document.py deleted file mode 100644 index 37615c786..000000000 --- a/mpcontribs-api/mpcontribs/api/notebooks/document.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import boto3 -import hashlib - -from io import BytesIO -from mongoengine import signals -from base64 import b64decode, b64encode -from flask_mongoengine.documents import Document -from mongoengine.fields import DictField, StringField, IntField, ListField -from mongoengine.queryset.manager import queryset_manager - -BUCKET = os.environ.get("S3_IMAGES_BUCKET", "mpcontribs-images") -S3_DOWNLOAD_URL = f"https://{BUCKET}.s3.amazonaws.com" -s3_client = boto3.client("s3") - - -class Kernelspec(DictField): - name = StringField(required=True, default="python3") - display_name = StringField(required=True, default="Python 3") - language = StringField() - - -class CodemirrorMode(DictField): - name = StringField(required=True, default="ipython") - version = IntField(required=True, default=3) - - -class LanguageInfo(DictField): - name = StringField(required=True, default="python") - file_extension = StringField() - mimetype = StringField() - nbconvert_exporter = StringField() - pygments_lexer = StringField() - version = StringField() - codemirror_mode = DictField( - CodemirrorMode(), default=CodemirrorMode, help_text="codemirror" - ) - - -class Metadata(DictField): - kernelspec = DictField( - Kernelspec(), required=True, help_text="kernelspec", default=Kernelspec - ) - language_info = DictField( - LanguageInfo(), required=True, help_text="language info", default=LanguageInfo - ) - - -class Cell(DictField): - cell_type = StringField(required=True, default="code", help_text="cell type") - metadata = DictField(help_text="cell metadata") - source = StringField(required=True, default="print('hello')", help_text="source") - outputs = ListField( - DictField(), required=True, help_text="outputs", default=lambda: [DictField()] - ) - execution_count = IntField(help_text="exec count") - - -class Notebooks(Document): - nbformat = IntField(default=4, help_text="nbformat version") - nbformat_minor = IntField(default=4, help_text="nbformat minor version") - metadata = DictField(Metadata(), help_text="notebook metadata") - cells = ListField(Cell(), max_length=30, help_text="cells") - meta = {"collection": "notebooks"} - - problem_key = "application/vnd.plotly.v1+json" - escaped_key = problem_key.replace(".", "~dot~") - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.only("nbformat", "nbformat_minor") - - @classmethod - def post_init(cls, sender, document, **kwargs): - if document.id: - document.transform(incoming=False) - - def transform(self, incoming=True): - if incoming: - old_key = self.problem_key - new_key = self.escaped_key - else: - old_key = self.escaped_key - new_key = self.problem_key - - for cell in self.cells: - for output in cell.get("outputs", []): - data = output.get("data", {}) - if old_key in data: - output["data"][new_key] = output["data"].pop(old_key) - - if "image/png" in data: - if incoming: - contents = data.pop("image/png") # base64 encoded - key = hashlib.sha1(contents.encode("utf-8")).hexdigest() - s3_client.put_object( - Bucket=BUCKET, - Key=key, - ContentType="image/png", - Body=b64decode(contents), - ) - data["image/png"] = key - elif len(data["image/png"]) == 40: - key = data.pop("image/png") - # TODO catch key doesn't exist - retr = s3_client.get_object(Bucket=BUCKET, Key=key) - gzip_buffer = BytesIO(retr["Body"].read()) - data["image/png"] = b64encode(gzip_buffer.getvalue()).decode() - - def clean(self): - self.transform() - - -signals.post_init.connect(Notebooks.post_init, sender=Notebooks) diff --git a/mpcontribs-api/mpcontribs/api/notebooks/views.py b/mpcontribs-api/mpcontribs/api/notebooks/views.py deleted file mode 100644 index e3bf88af8..000000000 --- a/mpcontribs-api/mpcontribs/api/notebooks/views.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import time -import requests -import flask_mongorest - -from rq import get_current_job -from rq.job import Job -from gevent import sleep -from nbformat import v4 as nbf -from flask_rq2 import RQ -from flask import Blueprint, request, abort, jsonify, current_app -from flask_mongorest import operators as ops -from flask_mongorest.methods import Fetch, BulkFetch -from flask_mongorest.resources import Resource -from mongoengine.errors import DoesNotExist -from mongoengine.queryset.visitor import Q - -from mpcontribs.api import get_kernel_endpoint, get_logger -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.contributions.document import Contributions -from mpcontribs.api.notebooks.document import Notebooks -from mpcontribs.api.notebooks import run_cells - - -logger = get_logger(__name__) -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -notebooks = Blueprint("notebooks", __name__, template_folder=templates) - -MPCONTRIBS_API_HOST = os.environ.get("MPCONTRIBS_API_HOST", "default") -ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "admin") - -rq = RQ() -rq.default_queue = f"notebooks_{MPCONTRIBS_API_HOST}" -rq.queues = [rq.default_queue] - - -class NotebooksResource(Resource): - document = Notebooks - filters = {"id": [ops.In, ops.Exact]} - fields = ["id", "nbformat", "nbformat_minor"] - paginate = True - default_limit = 10 - max_limit = 100 - - @staticmethod - def get_optional_fields(): - return ["metadata", "cells"] - - -class NotebooksView(SwaggerView): - resource = NotebooksResource - methods = [Fetch, BulkFetch] - - -def execute_cells(cid, cells): - ntries = 0 - while ntries < 5: - for kernel_id, running_cid in current_app.kernels.items(): - if running_cid is None: - current_app.kernels[kernel_id] = cid - try: - outputs = run_cells(kernel_id, cid, cells) - except: - current_app.kernels[kernel_id] = None - raise - - current_app.kernels[kernel_id] = None - return outputs - else: - logger.warning(f"{kernel_id} busy with {running_cid}") - - logger.warning("WAITING for a kernel to become available") - sleep(5) - ntries += 1 - - -@notebooks.route("/build") -def build(): - if not getattr(current_app, "kernels", None): - abort(404, description="No kernels available.") - - cids = request.args.get("cids") - projects = request.args.get("projects") - force = bool(request.args.get("force", 0)) - kwargs = dict(force=force) - - if projects: - kwargs["projects"] = projects.split(",") - - if cids: - kwargs["cids"] = cids.split(",") - - if len(kwargs.get("cids", [])) == 1: - return jsonify(make(**kwargs)) - - job = make.queue(**kwargs) - return job.id - - -def restart_kernels(): - """use to avoid run-away memory""" - kernel_ids = [k for k, v in current_app.kernels.items() if v is None] - - for kernel_id in kernel_ids: - kernel_url = get_kernel_endpoint(kernel_id) + "/restart" - requests.post(kernel_url, json={}) - cells = [nbf.new_code_cell("\n".join([ - "from mpcontribs.client import Client", - "print('client imported')" - ]))] - run_cells(kernel_id, "import_client", cells) - - -@notebooks.route('/result', defaults={'job_id': None}) -@notebooks.route("/result/") -def result(job_id): - if not current_app.kernels: - abort(404, description="No kernels available.") - - if not job_id: - job_id = f"cron-{current_app.cron_job_id}" - - try: - job = Job.fetch(job_id, connection=rq.connection) - except Exception as exception: - abort(404, description=exception) - - if not job.is_finished: - return job.get_status() - elif not job.result: - description = f"No result for job_id {job.id} (exc: {job.exc_info})." - abort(404, description=description) - - return jsonify(job.result) - - -@rq.job() -def make(projects=None, cids=None, force=False): - """build the notebook / details page""" - start = time.perf_counter() - remaining_time = rq.default_timeout - 5 - mask = ["id", "needs_build", "notebook"] - query = Q() - - if projects: - query &= Q(project__in=projects) - if cids: - query &= Q(id__in=cids) - if not force: - query &= Q(needs_build=True) | Q(needs_build__exists=False) - - job = get_current_job() - ret = {"input": {"projects": projects, "cids": cids, "force": force}} - if job: - ret["job"] = { - "id": job.id, - "enqueued_at": job.enqueued_at.isoformat(), - "started_at": job.started_at.isoformat() - } - - exclude = list(Contributions._fields.keys()) - documents = Contributions.objects(query).exclude(*exclude).only(*mask) - total = documents.count() - count = 0 - - for idx, document in enumerate(documents): - stop = time.perf_counter() - remaining_time -= stop - start - - if remaining_time < 0: - if job: - restart_kernels() - - ret["result"] = {"status": "TIMEOUT", "count": count, "total": total} - return ret - - start = time.perf_counter() - - if not force and document.notebook and \ - not getattr(document, "needs_build", True): - continue - - if document.notebook: - try: - nb = Notebooks.objects.get(id=document.notebook.id) - nb.delete() - document.update(unset__notebook="") - logger.debug(f"Notebook {document.notebook.id} deleted.") - except DoesNotExist: - pass - - cid = str(document.id) - logger.debug(f"prep notebook for {cid} ...") - document.reload("tables", "structures", "attachments") - - cells = [ - # define client only once in kernel - # avoids API calls for regex expansion for query parameters - nbf.new_code_cell("\n".join([ - "if 'client' not in locals():", - "\tclient = Client(", - f'\t\theaders={{"X-Authenticated-Groups": "{ADMIN_GROUP}"}},', - f'\t\thost="{MPCONTRIBS_API_HOST}"', - "\t)", - "print(client.get_totals())", - # return something. See while loop in `run_cells` - ])), - nbf.new_code_cell("\n".join([ - f'c = client.get_contribution("{document.id}")', - 'c.display()' - ])), - ] - - if document.tables: - cells.append(nbf.new_markdown_cell("## Tables")) - for table in document.tables: - cells.append( - nbf.new_code_cell("\n".join([ - f't = client.get_table("{table.id}")', - 't.display()' - ])) - ) - - if document.structures: - cells.append(nbf.new_markdown_cell("## Structures")) - for structure in document.structures: - cells.append( - nbf.new_code_cell("\n".join([ - f's = client.get_structure("{structure.id}")', - 's.display()' - ])) - ) - - if document.attachments: - cells.append(nbf.new_markdown_cell("## Attachments")) - for attachment in document.attachments: - cells.append( - nbf.new_code_cell("\n".join([ - f'a = client.get_attachment("{attachment.id}")', - 'a.info()' - ])) - ) - - try: - outputs = execute_cells(cid, cells) - except Exception as e: - if job: - restart_kernels() - - ret["result"] = { - "status": "ERROR", "cid": cid, "count": count, "total": total, "exc": str(e) - } - return ret - - if not outputs: - if job: - restart_kernels() - - ret["result"] = { - "status": "ERROR: NO OUTPUTS", "cid": cid, "count": count, "total": total - } - return ret - - for idx, output in outputs.items(): - cells[idx]["outputs"] = output - - doc = nbf.new_notebook() - doc["cells"] = [ - nbf.new_code_cell("from mpcontribs.client import Client"), - nbf.new_code_cell(f'client = Client()'), - ] - doc["cells"] += cells[1:] # skip localhost Client - - try: - nb = Notebooks(**doc).save() - document.update(notebook=nb, needs_build=False) - except Exception as e: - if job: - restart_kernels() - - ret["result"] = { - "status": "ERROR", "cid": cid, "count": count, "total": total, "exc": str(e) - } - return ret - - count += 1 - - if total and job: - restart_kernels() - - ret["result"] = {"status": "COMPLETED", "count": count, "total": total} - return ret diff --git a/mpcontribs-api/mpcontribs/api/projects/document.py b/mpcontribs-api/mpcontribs/api/projects/document.py deleted file mode 100644 index e172e924b..000000000 --- a/mpcontribs-api/mpcontribs/api/projects/document.py +++ /dev/null @@ -1,387 +0,0 @@ -# -*- coding: utf-8 -*- -import urllib - -from math import isnan -from atlasq import AtlasManager, AtlasQ -from flatten_dict import flatten -from boltons.iterutils import remap -from collections import ChainMap -from flask import current_app, render_template, url_for, request -from mongoengine import Document -from marshmallow import ValidationError -from marshmallow.fields import String -from marshmallow.validate import Email as EmailValidator -from marshmallow_mongoengine.conversion import params -from marshmallow_mongoengine.conversion.fields import register_field -from mongoengine import EmbeddedDocument, signals -from mongoengine.queryset.manager import queryset_manager -from mongoengine.fields import ( - StringField, - BooleanField, - DictField, - URLField, - EmailField, - DecimalField, - FloatField, - IntField, - EmbeddedDocumentListField, - EmbeddedDocumentField, -) -from mpcontribs.api import send_email, valid_key, valid_dict, delimiter, enter - -PROVIDERS = {"github", "google", "facebook", "microsoft", "amazon", "portier"} -MAX_COLUMNS = 160 - - -def visit(path, key, value): - from mpcontribs.api.contributions.document import quantity_keys - - # pull out units - if isinstance(value, dict) and "unit" in value: - return key, value["unit"] - elif isinstance(value, (str, bool)) and key not in quantity_keys: - return key, None - - return True - - -class ProviderEmailField(EmailField): - """Field to validate usernames of format :""" - - def validate(self, value): - if value.count(":") != 1: - self.error(self.error_msg % value) - - provider, email = value.split(":", 1) - - if provider not in PROVIDERS: - self.error("{} {}".format(self.error_msg % value, "(invalid provider)")) - - super().validate(email) - - -class ProviderEmailValidator(EmailValidator): - def __call__(self, value): - message = self._format_error(value) - - if value.count(":") != 1: - raise ValidationError(message) - - provider, email = value.split(":", 1) - - if provider not in PROVIDERS: - raise ValidationError(message) - - super().__call__(email) - return value - - -class ProviderEmail(String): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - validator = ProviderEmailValidator(error="Not a valid MP username ({input}).") - self.validators.insert(0, validator) - - -def dict_wo_nans(d): - return {k: v for k, v in d.items() if k in ["min", "max"] and not isnan(v)} - - -class Column(EmbeddedDocument): - path = StringField(required=True, help_text="column path in dot-notation") - min = FloatField(required=True, default=float("nan"), help_text="column minimum") - max = FloatField(required=True, default=float("nan"), help_text="column maximum") - unit = StringField(required=True, default="NaN", help_text="column unit") - - def __eq__(self, other): - if isinstance(other, self.__class__): - return dict_wo_nans(self._data) == dict_wo_nans(other._data) - return False - - -class Reference(EmbeddedDocument): - label = StringField( - required=True, - min_length=3, - max_length=20, - help_text="label", - validation=valid_key, - ) - url = URLField(required=True, help_text="URL") - - -class Stats(EmbeddedDocument): - columns = IntField(required=True, default=0, help_text="#columns") - contributions = IntField(required=True, default=0, help_text="#contributions") - tables = IntField(required=True, default=0, help_text="#tables") - structures = IntField(required=True, default=0, help_text="#structures") - attachments = IntField(required=True, default=0, help_text="#attachments") - size = DecimalField(required=True, default=0, precision=1, help_text="size in MB") - - -class Projects(Document): - __project_regex__ = "^[a-zA-Z0-9_]{3,31}$" - name = StringField( - min_length=3, - max_length=30, - regex=__project_regex__, - primary_key=True, - help_text=f"project name/slug (valid format: `{__project_regex__}`)", - ) - is_public = BooleanField( - required=True, default=False, help_text="public/private project" - ) - title = StringField( - min_length=5, - max_length=30, - required=True, - unique=True, - help_text="short title for the project/dataset", - ) - long_title = StringField( - min_length=5, - max_length=55, - help_text="optional full title for the project/dataset", - ) - authors = StringField( - required=True, - help_text="comma-separated list of authors", - # TODO change to EmbeddedDocumentListField - ) - description = StringField( - min_length=5, - max_length=2000, - required=True, - help_text="brief description of the project", - ) - references = EmbeddedDocumentListField( - Reference, - required=True, - min_length=1, - max_length=20, - help_text="list of references", - ) - license = StringField( - choices=["CCA4", "CCPD"], - default="CCA4", - required=True, - help_text="license (see https://materialsproject.org/about/terms)", - ) - other = DictField(validation=valid_dict, null=True, help_text="other information") - owner = ProviderEmailField( - unique_with="name", help_text="owner / corresponding email" - ) - is_approved = BooleanField( - required=True, default=False, help_text="project approved?" - ) - unique_identifiers = BooleanField( - required=True, default=True, help_text="identifiers unique?" - ) - columns = EmbeddedDocumentListField(Column, max_length=MAX_COLUMNS) - stats = EmbeddedDocumentField(Stats, required=True, default=Stats) - atlas = AtlasManager("mpcontribs-dev-project-search") - meta = { - "collection": "projects", - "indexes": ["is_public", "title", "owner", "is_approved", "unique_identifiers"], - } - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.only( - "name", "is_public", "title", "owner", "is_approved", "unique_identifiers" - ) - - @classmethod - def atlas_filter(cls, term): - # NOTE dynamic index, use `name` as placeholder for wildcard path - return AtlasQ(name=term) - - @classmethod - def post_save(cls, sender, document, **kwargs): - admin_email = current_app.config["MAIL_DEFAULT_SENDER"] - scheme = "http" if current_app.config["DEBUG"] else "https" - - if kwargs.get("created"): - ts = current_app.config["USTS"] - email_project = [document.owner, document.name] - token = ts.dumps(email_project) - link = url_for( - "projects.applications", token=token, _scheme=scheme, _external=True - ) - url = url_for( - "projectsFetch", pk=document.name, _scheme=scheme, _external=True - ) - url += "?_fields=_all" - html = render_template("admin_email.html", url=url, link=link) - send_email(admin_email, f'New project "{document.name}"', html) - else: - delta_set, delta_unset = document._delta() - - if "is_approved" in delta_set and document.is_approved: - subject = f'Your project "{document.name}" has been approved' - netloc = urllib.parse.urlparse(request.url).netloc.replace("-api", "") - portal = f"{scheme}://{netloc}" - html = render_template( - "owner_email.html", - approved=True, - admin_email=admin_email, - host=portal, - project=document.name, - ) - owner_email = document.owner.split(":", 1)[1] - send_email(owner_email, subject, html) - - if ( - "columns" in delta_set - or "columns" in delta_unset - or (not delta_set and not delta_unset) - ): - from mpcontribs.api.contributions.document import ( - Contributions, - COMPONENTS, - ) - - columns = {} - ncontribs = Contributions.objects(project=document.id).count() - - if "columns" in delta_set: - # document.columns updated by the user as intended - for col in document.columns: - columns[col.path] = col - elif "columns" in delta_unset or ncontribs: - # document.columns unset by user to reinit all columns from DB - # -> get paths and units across all contributions from DB - pipeline = [ - {"$match": {"project": document.id}}, - {"$sample": {"size": 1000}}, - {"$project": {"data": 1}}, - ] - result = Contributions.objects.aggregate(pipeline) - merged = ChainMap(*result) - flat = flatten( - remap(merged, visit=visit, enter=enter), reducer="dot" - ) - - for k, v in flat.items(): - if k.startswith("data."): - columns[k] = Column(path=k) - if v is not None: - columns[k].unit = v - - # start pipeline for stats: match project - pipeline = [{"$match": {"project": document.id}}] - - # resolve/lookup component fields - # NOTE also includes dynamic document fields - # for component in COMPONENTS.keys(): - # pipeline.append( - # { - # "$lookup": { - # "from": component, - # "localField": component, - # "foreignField": "_id", - # "as": component, - # } - # } - # ) - - # document size and attachment content size - project_stage = { - # "_id": 0, - # "size": {"$bsonSize": "$$ROOT"}, - # "contents": { - # "$map": { # attachment sizes - # "input": "$attachments", - # "as": "attm", - # "in": {"$toInt": "$$attm.content"}, - # } - # }, - } - - # number of components - for component in COMPONENTS.keys(): - project_stage[component] = {"$size": f"${component}"} - - # filter/forward number columns - min_max_paths = [ - path for path, col in columns.items() if col["unit"] != "NaN" - ] - for path in min_max_paths: - field = f"{path}{delimiter}value" - project_stage[field] = { - "$cond": { - "if": {"$isNumber": f"${field}"}, - "then": f"${field}", - "else": "$$REMOVE", - } - } - - # add project stage to pipeline - pipeline.append({"$project": project_stage}) - - # forward fields and sum attachment contents - project_stage_2 = {k: 1 for k in project_stage.keys()} - # project_stage_2["contents"] = {"$sum": "$contents"} - pipeline.append({"$project": project_stage_2}) - - # total size and total number of components - group_stage = { - "_id": None, - # "size": {"$sum": {"$add": ["$size", "$contents"]}}, - } - for component in COMPONENTS.keys(): - group_stage[component] = {"$sum": f"${component}"} - - # determine min/max for columns - for path in min_max_paths: - field = f"{path}{delimiter}value" - for k in ["min", "max"]: - clean_path = path.replace(delimiter, "__") - key = f"{clean_path}__{k}" - group_stage[key] = {f"${k}": f"${field}"} - - # append group stage and run pipeline - pipeline.append({"$group": group_stage}) - result = list(Contributions.objects.aggregate(pipeline)) - - # set min/max for columns - min_max = {} if not result else result[0] - for clean_path in min_max_paths: - for k in ["min", "max"]: - path = clean_path.replace(delimiter, "__") - m = min_max.get(f"{path}__{k}") - if m is not None: - setattr(columns[clean_path], k, m) - - # prep and save stats - stats_kwargs = {"columns": len(columns), "contributions": ncontribs} - if result and result[0]: - # stats_kwargs["size"] = result[0]["size"] / 1024 / 1024 - for component in COMPONENTS.keys(): - stats_kwargs[component] = result[0].get(component, 0) - if stats_kwargs[component] > 0: - columns[component] = Column(path=component) - - stats = Stats(**stats_kwargs) - document.update(stats=stats, columns=columns.values()) - - @classmethod - def post_delete(cls, sender, document, **kwargs): - admin_email = current_app.config["MAIL_DEFAULT_SENDER"] - subject = f'Your project "{document.name}" has been deleted' - html = render_template( - "owner_email.html", - approved=False, - admin_email=admin_email, - project=document.name, - ) - owner_email = document.owner.split(":", 1)[1] - send_email(owner_email, subject, html) - - -register_field( - ProviderEmailField, ProviderEmail, available_params=(params.LengthParam,) -) -signals.post_save.connect(Projects.post_save, sender=Projects) -signals.post_delete.connect(Projects.post_delete, sender=Projects) -Projects.atlas.index._set_indexed_fields({"type": "document", "dynamic": True}) diff --git a/mpcontribs-api/mpcontribs/api/projects/views.py b/mpcontribs-api/mpcontribs/api/projects/views.py deleted file mode 100644 index 1a000107b..000000000 --- a/mpcontribs-api/mpcontribs/api/projects/views.py +++ /dev/null @@ -1,183 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import flask_mongorest - -from mongoengine.queryset import DoesNotExist -from flask import Blueprint, current_app, url_for, jsonify, abort, request -from flask_mongorest.resources import Resource -from flask_mongorest import operators as ops -from flask_mongorest.methods import Fetch, Create, Delete, Update, BulkFetch -from werkzeug.exceptions import Unauthorized - -from mpcontribs.api import FILTERS -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.projects.document import Projects, Column, Reference, Stats - -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -projects = Blueprint("projects", __name__, template_folder=templates) -MAX_PROJECTS = int(os.environ.get("MAX_PROJECTS", 3)) - - -class ColumnResource(Resource): - document = Column - - -class ReferenceResource(Resource): - document = Reference - - -class StatsResource(Resource): - document = Stats - - -class ProjectsResource(Resource): - document = Projects - related_resources = { - "columns": ColumnResource, - "references": ReferenceResource, - "stats": StatsResource, - } - filters = { - "name": FILTERS["STRINGS"], - "is_public": [ops.Boolean], - "title": FILTERS["STRINGS"], - "long_title": FILTERS["LONG_STRINGS"], - "authors": FILTERS["LONG_STRINGS"], - "description": FILTERS["LONG_STRINGS"], - "owner": FILTERS["STRINGS"], - "license": FILTERS["STRINGS"], - "is_approved": [ops.Boolean], - "unique_identifiers": [ops.Boolean], - "columns": [ops.Size], - "stats__columns": FILTERS["NUMBERS"], - "stats__contributions": FILTERS["NUMBERS"], - "stats__tables": FILTERS["NUMBERS"], - "stats__structures": FILTERS["NUMBERS"], - "stats__attachments": FILTERS["NUMBERS"], - "stats__size": FILTERS["NUMBERS"], - } - fields = [ - "name", - "is_public", - "title", - "owner", - "is_approved", - "unique_identifiers", - ] - allowed_ordering = ["name", "is_public", "title"] - paginate = True - default_limit = 100 - max_limit = 500 - - @staticmethod - def get_optional_fields(): - return [ - "long_title", - "authors", - "description", - "references", - "license", - "other", - "columns", - "stats", - ] - - -class ProjectsView(SwaggerView): - resource = ProjectsResource - methods = [Fetch, Create, Delete, Update, BulkFetch] - - def has_add_permission(self, request, obj): - if self.is_anonymous(request): - return False - - obj.owner = request.headers.get("X-Consumer-Username") - if self.is_admin(request): - return True - - data = request.json - if "is_approved" in data or "is_public" in data: - raise Unauthorized("Projects cannot be approved or published on creation.") - - # limit the number of projects a user can own - nr_projects = Projects.objects(owner=obj.owner).count() - if nr_projects > MAX_PROJECTS: - raise Unauthorized(f"{obj.owner} already owns {nr_projects} projects.") - - return True - - def has_change_permission(self, request, obj): - if self.is_anonymous(request): - return False - - if self.is_admin(request): - return True - - if not self.is_project_user(request, obj): - raise Unauthorized( - "Only project owners and collaborators can edit projects." - ) - - update = request.json - if "is_approved" in update: - raise Unauthorized("Only admins can (un)approve projects.") - - if "is_public" in update and not obj.is_approved: - raise Unauthorized("Projects can only be published after admin approval.") - - return True - - -@projects.route("/applications/", defaults={"action": None}) -@projects.route("/applications//") -def applications(token, action): - ts = current_app.config["USTS"] - owner, project = ts.loads(token) - - try: - obj = Projects.objects.get(name=project, owner=owner, is_approved=False) - except DoesNotExist: - return f"{project} for {owner} already approved or denied." - - actions = ["approve", "deny"] - if action not in actions: - response = f"

{project}

    " - scheme = "http" if current_app.config["DEBUG"] else "https" - for a in actions: - u = url_for( - "projects.applications", - token=token, - action=a, - _scheme=scheme, - _external=True, - ) - response += f'
  • {a}
  • ' - return response + "
" - - if action == "approve": - obj.reload(*obj._fields.keys()) - obj.is_approved = True - obj.save() # post_save (created=False) sends notification when `is_approved` set - else: - obj.delete() # post_delete signal sends notification - - return f'{project} {action.replace("y", "ie")}d and {owner} notified.' - - -@projects.route("/search") -def search(): - term = request.args.get("term") - if not term: - abort(404, description="Missing search term.") - - pipeline = [ - { - "$search": { - "index": "mpcontribs-dev-project-search", - "text": {"path": {"wildcard": "*"}, "query": term}, - } - }, - {"$project": {"_id": 1}}, - ] - result = [p["_id"] for p in Projects.objects().aggregate(pipeline)] - return jsonify(result) diff --git a/mpcontribs-api/mpcontribs/api/structures/document.py b/mpcontribs-api/mpcontribs/api/structures/document.py deleted file mode 100644 index 29b8a333d..000000000 --- a/mpcontribs-api/mpcontribs/api/structures/document.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from hashlib import md5 -from flask_mongoengine.documents import Document -from mongoengine import signals -from mongoengine.fields import StringField, FloatField, ListField, DictField -from mongoengine.queryset.manager import queryset_manager -from pymatgen.core import Structure -from pymatgen.io.cif import CifWriter -from pymatgen.symmetry.analyzer import SymmetryUndeterminedError - - -class Structures(Document): - name = StringField(required=True, help_text="name") - lattice = DictField(required=True, help_text="lattice") - sites = ListField(DictField(), required=True, help_text="sites") - charge = FloatField(null=True, help_text="charge") - md5 = StringField(regex=r"^[a-z0-9]{32}$", unique=True, help_text="md5 sum") - cif = StringField(help_text="CIF string") - meta = {"collection": "structures", "indexes": ["name", "md5"]} - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.only("name", "md5") - - @classmethod - def pre_save_post_validation(cls, sender, document, **kwargs): - from mpcontribs.api.structures.views import StructuresResource - - resource = StructuresResource() - d = resource.serialize(document, fields=["lattice", "sites", "charge"]) - s = json.dumps(d, sort_keys=True).encode("utf-8") - document.md5 = md5(s).hexdigest() - structure = Structure.from_dict(d) - writer = None - - for symprec_log in range(-10, 0, 3): - try: - writer = CifWriter(structure, symprec=10**symprec_log) - break - except SymmetryUndeterminedError: - continue - - if not writer: - # save CIF string without symmetry information - writer = CifWriter(structure) - - document.cif = writer.__str__() - - -signals.pre_save_post_validation.connect( - Structures.pre_save_post_validation, sender=Structures -) diff --git a/mpcontribs-api/mpcontribs/api/structures/views.py b/mpcontribs-api/mpcontribs/api/structures/views.py deleted file mode 100644 index 07aeb7901..000000000 --- a/mpcontribs-api/mpcontribs/api/structures/views.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import flask_mongorest -from flask_mongorest.resources import Resource -from flask_mongorest import operators as ops -from flask_mongorest.methods import Fetch, BulkFetch, Download -from flask import Blueprint - -from mpcontribs.api import FILTERS -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.structures.document import Structures - -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -structures = Blueprint("structures", __name__, template_folder=templates) - - -class StructuresResource(Resource): - document = Structures - filters = { - "id": [ops.In, ops.Exact], - "md5": [ops.In, ops.Exact], - "name": FILTERS["STRINGS"], - "sites": [ops.Size] - } - fields = ["id", "name", "md5"] - allowed_ordering = ["name"] - paginate = True - default_limit = 10 - max_limit = 100 - download_formats = ["json", "csv"] - - @staticmethod - def get_optional_fields(): - return ["lattice", "sites", "charge", "cif"] - - -class StructuresView(SwaggerView): - resource = StructuresResource - methods = [Fetch, BulkFetch, Download] diff --git a/mpcontribs-api/mpcontribs/api/tables/__init__.py b/mpcontribs-api/mpcontribs/api/tables/__init__.py deleted file mode 100644 index e809e5932..000000000 --- a/mpcontribs-api/mpcontribs/api/tables/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""document and views for tables collection""" diff --git a/mpcontribs-api/mpcontribs/api/tables/document.py b/mpcontribs-api/mpcontribs/api/tables/document.py deleted file mode 100644 index dc52876df..000000000 --- a/mpcontribs-api/mpcontribs/api/tables/document.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -from hashlib import md5 -from flask_mongoengine.documents import DynamicDocument -from mongoengine import signals, EmbeddedDocument -from mongoengine.fields import StringField, ListField, IntField, EmbeddedDocumentField -from mongoengine.queryset.manager import queryset_manager - -from mpcontribs.api.contributions.document import format_cell, get_resource, get_md5, COMPONENTS - - -class Labels(EmbeddedDocument): - index = StringField(help_text="index name / x-axis label") - value = StringField(help_text="columns name / y-axis label") - variable = StringField(help_text="legend name") - - -class Attributes(EmbeddedDocument): - title = StringField(help_text="title") - labels = EmbeddedDocumentField(Labels) - - -class Tables(DynamicDocument): - name = StringField(required=True, help_text="name / title") - attrs = EmbeddedDocumentField(Attributes) - index = ListField(StringField(), required=True, help_text="index column") - columns = ListField(StringField(), required=True, help_text="column names/headers") - data = ListField(ListField(StringField()), required=True, help_text="table rows") - md5 = StringField(regex=r"^[a-z0-9]{32}$", unique=True, help_text="md5 sum") - total_data_rows = IntField(help_text="total number of rows") - meta = {"collection": "tables", "indexes": [ - "name", "columns", "md5", "attrs.title", - "attrs.labels.index", "attrs.labels.value", "attrs.labels.variable" - ]} - - @queryset_manager - def objects(doc_cls, queryset): - return queryset.only("name", "md5", "attrs", "columns", "total_data_rows") - - @classmethod - def post_init(cls, sender, document, **kwargs): - document.data = [[format_cell(cell) for cell in row] for row in document.data] - - @classmethod - def pre_save_post_validation(cls, sender, document, **kwargs): - # significant digits, md5 and total_data_rows - resource = get_resource("tables") - document.md5 = get_md5(resource, document, COMPONENTS["tables"]) - document.total_data_rows = len(document.data) - - -signals.post_init.connect(Tables.post_init, sender=Tables) -signals.pre_save_post_validation.connect(Tables.pre_save_post_validation, sender=Tables) diff --git a/mpcontribs-api/mpcontribs/api/tables/views.py b/mpcontribs-api/mpcontribs/api/tables/views.py deleted file mode 100644 index a8f657998..000000000 --- a/mpcontribs-api/mpcontribs/api/tables/views.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import flask_mongorest -from flask_mongorest.resources import Resource -from flask_mongorest import operators as ops -from flask_mongorest.methods import Fetch, BulkFetch, Download -from flask_mongorest.exceptions import UnknownFieldError -from flask import Blueprint - -from mpcontribs.api import FILTERS -from mpcontribs.api.core import SwaggerView -from mpcontribs.api.tables.document import Tables, Attributes, Labels - -templates = os.path.join(os.path.dirname(flask_mongorest.__file__), "templates") -tables = Blueprint("tables", __name__, template_folder=templates) - - -class LabelsResource(Resource): - document = Labels - - -class AttributesResource(Resource): - document = Attributes - related_resources = {"labels": LabelsResource} - - -class TablesResource(Resource): - document = Tables - related_resources = {"attrs": AttributesResource} - filters = { - "id": [ops.In, ops.Exact], - "md5": [ops.In, ops.Exact], - "name": FILTERS["STRINGS"], - "columns": [ops.Size], - "attrs__title": FILTERS["STRINGS"], - "attrs__labels__index": FILTERS["STRINGS"], - "attrs__labels__value": FILTERS["STRINGS"], - "attrs__labels__variable": FILTERS["STRINGS"], - } - fields = [ - "id", "name", "md5", "attrs", "columns", "total_data_rows", "total_data_pages" - ] - allowed_ordering = ["name", "total_data_rows"] - paginate = True - default_limit = 10 - max_limit = 100 - fields_to_paginate = {"data": [20, 1000]} - download_formats = ["json", "csv"] - - @staticmethod - def get_optional_fields(): - return ["index", "data"] - - def value_for_field(self, obj, field): - if field == "total_data_pages": - if obj.total_data_rows is None: - return None - - per_page_default = self.fields_to_paginate["data"][0] - per_page = int(self.params.get("data_per_page", per_page_default)) - total_data_pages = int(obj.total_data_rows / per_page) - total_data_pages += bool(obj.total_data_rows % per_page) - return total_data_pages - else: - raise UnknownFieldError - - -class TablesView(SwaggerView): - resource = TablesResource - methods = [Fetch, BulkFetch, Download] diff --git a/mpcontribs-api/mpcontribs/api/templates/admin_email.html b/mpcontribs-api/mpcontribs/api/templates/admin_email.html deleted file mode 100644 index 30267cf34..000000000 --- a/mpcontribs-api/mpcontribs/api/templates/admin_email.html +++ /dev/null @@ -1 +0,0 @@ -Go to {{url}} for more info about the project. Approve or deny this request: {{link}}. A notification email with your decision will automatically be sent to the user. diff --git a/mpcontribs-api/mpcontribs/api/templates/card_bootstrap.html b/mpcontribs-api/mpcontribs/api/templates/card_bootstrap.html deleted file mode 100644 index b8a904560..000000000 --- a/mpcontribs-api/mpcontribs/api/templates/card_bootstrap.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
-
-

- {{ title }} - {% if references %} - - {% for ref in references %} - [{{ref.label}}] - {% endfor %} - - {% endif %} - Details -

- {{ authors.main }} - {% if authors.etal %} - et al. - - {% endif %} -
- -
-
-
- {{ descriptions.0 }}. - {% if descriptions.1 %} - More » - - {% endif %} -
-
{{ data|safe }}
-
-
- -
diff --git a/mpcontribs-api/mpcontribs/api/templates/card_bulma.html b/mpcontribs-api/mpcontribs/api/templates/card_bulma.html deleted file mode 100644 index ce4a01d92..000000000 --- a/mpcontribs-api/mpcontribs/api/templates/card_bulma.html +++ /dev/null @@ -1,60 +0,0 @@ -
-
-
- {% if references %} - - {% endif %} - -
- {{ authors.main }} - {% if authors.etal %} - et al. - - {% endif %} -
-
- {{ descriptions.0 }}. - {% if descriptions.1 %} - More » - - {% endif %} -
-
- {{ data|safe }} -
-
- -
- -
diff --git a/mpcontribs-api/mpcontribs/api/templates/owner_email.html b/mpcontribs-api/mpcontribs/api/templates/owner_email.html deleted file mode 100644 index 2eb526dce..000000000 --- a/mpcontribs-api/mpcontribs/api/templates/owner_email.html +++ /dev/null @@ -1 +0,0 @@ -Your project "{{project}}" has been {% if approved %}approved. You can now add all your contributions and publish the project.{% else %}denied and deleted.{% endif %} diff --git a/mpcontribs-api/pyproject.toml b/mpcontribs-api/pyproject.toml index d64166fd3..87f12d487 100644 --- a/mpcontribs-api/pyproject.toml +++ b/mpcontribs-api/pyproject.toml @@ -10,94 +10,86 @@ relative_to = "__file__" include-package-data = true [tool.setuptools.packages.find] -where = ["."] -exclude = ["scripts","supervisord"] -include = ["mpcontribs.api"] +where = ["src"] +include = ["mpcontribs_api*"] [project] name = "mpcontribs-api" dynamic = ["version"] -requires-python = ">=3.11" +requires-python = ">=3.14" description="API for community-contributed Materials Project data" license = "BSD-3-Clause-LBNL" authors = [ - {name = "Patrick Huck", email = "phuck@lbl.gov"}, - {name = "The Materials Project", email="feedback@materialsproject.org"}, + {name="Patrick Huck", email="phuck@lbl.gov"}, + {name="Brendan Foley", email="bfoley@lbl.gov"}, + {name="The Materials Project", email="feedback@materialsproject.org"}, ] dependencies = [ - "numpy", - "apispec<6", - "asn1crypto", - "blinker", - "boltons", - "css-html-js-minify", - "dateparser", - "ddtrace==4.3.0", - "dnspython", - "filetype", - "flasgger-tschaume>=0.9.7", - "flask-compress", - "flask-marshmallow", - "flask-mongorest-mpcontribs>=3.2.1", - "Flask-RQ2", - "gunicorn[gevent]==24.1.1", + # "pint>=0.24", + # "psycopg2-binary", + # "rq<=2.3.2", # see https://github.com/rq/Flask-RQ2/issues/620 + # "setproctitle", + # "uncertainties", + # "websocket_client", + # "zstandard", + "aioboto3>=15.5.0", + "beanie>=2.1.0", + "fastapi[standard]>=0.136.3", + "fastapi-filter>=2.0.1", "jinja2", - "json2html", - "marshmallow<4", - "more-itertools", - "nbformat", - "notebook<7", - "pint>=0.24", - "psycopg2-binary", + "opentelemetry-api>=1.42.1", + "opentelemetry-exporter-otlp-proto-grpc>=1.42.1", + "opentelemetry-instrumentation-fastapi>=0.63b1", + "opentelemetry-instrumentation-pymongo>=0.63b1", + "opentelemetry-sdk>=1.42.1", + "polars>=1.41.2", + "pydantic-settings>=2.14.1", "pymatgen", - "pyopenssl", - "python-snappy", - "rq<=2.3.2", # see https://github.com/rq/Flask-RQ2/issues/620 - "supervisor", - "setproctitle", - "uncertainties", - "websocket_client", - "zstandard", + "pymongo>=4.17.0", + "python-snappy>=0.7.3", + "structlog>=25.5.0", + "supervisor>=4.3.0", + "types-aiobotocore[s3]>=3.7.0", ] [project.urls] Homepage = "https://github.com/materialsproject/MPContribs" Documentation = "https://docs.materialsproject.org/services/mpcontribs" -[project.optional-dependencies] +[dependency-groups] dev = [ - "flake8", - "pytest", - "pytest-flake8", - "pytest-pycodestyle", - "pytest-xdist", -] -all = [ - "mpcontribs-api[dev]" + "ruff>=0.9.0", + "basedpyright>=1.29.0", + "pydocstringformatter>=0.7.0", + "pytest>=9.0.3", + "pytest-xdist>=3.8.0", + "httpx2>=0.28.0", + "pytest-asyncio>=1.4.0", ] -[tool.pytest] +[tool.pytest.ini_options] +pythonpath = ["src"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" markers = [ "base: basic resource testing", "extra: all extra views", + "db: requires a live MongoDB connection (set MPCONTRIBS_MONGO__URI)", ] -[tool.pycodestyle] -count = true -ignore = ["E121","E123","E126","E133","E226","E241","E242","E704","W503","W504","W505","E741","W605"] -max-line-length = 120 -statistics = true -exclude = ["flasgger","flask-mongorest"] +[tool.ruff] +line-length = 120 +exclude = ["tests/"] -[tool.flake8] -exclude = [".git","__pycache__","tests","flasgger","flask-mongorest"] -extend-ignore = ["E741",] -max-line-length = 120 +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E741", "B008"] -[tool.pydocstyle] -ignore = ["D105","D2","D4"] +[tool.pydocstringformatter] +max-line-length = 120 -[tool.mypy] -ignore_missing_imports = true -namespace_packages = true -python_version = 3.11 +[tool.basedpyright] +pythonVersion = "3.14" +typeCheckingMode = "standard" +extraPaths = ["src"] +ignore=["tests/"] diff --git a/mpcontribs-api/requirements/deployment.txt b/mpcontribs-api/requirements/deployment.txt deleted file mode 100644 index 03bbd3d93..000000000 --- a/mpcontribs-api/requirements/deployment.txt +++ /dev/null @@ -1,551 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --output-file=MPContribs/mpcontribs-api/requirements/deployment.txt MPContribs/mpcontribs-api/pyproject.toml python/requirements.txt -# -anyio==4.13.0 - # via jupyter-server -apispec==5.2.2 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -argon2-cffi==25.1.0 - # via - # jupyter-server - # notebook -argon2-cffi-bindings==25.1.0 - # via argon2-cffi -arrow==1.4.0 - # via isoduration -asn1crypto==1.5.1 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -asttokens==3.0.1 - # via stack-data -atlasq-tschaume==0.11.1.dev2 - # via flask-mongorest-mpcontribs -attrs==26.1.0 - # via - # jsonschema - # referencing -backports-zstd==1.5.0 - # via flask-compress -beautifulsoup4==4.15.0 - # via nbconvert -bibtexparser==1.4.4 - # via pymatgen-core -bleach[css]==6.4.0 - # via nbconvert -blinker==1.9.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -boltons==25.0.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -boto3==1.43.25 - # via flask-mongorest-mpcontribs -botocore==1.43.25 - # via - # boto3 - # s3transfer -brotli==1.2.0 - # via flask-compress -bytecode==0.18.1 - # via ddtrace -certifi==2026.5.20 - # via requests -cffi==2.0.0 - # via - # argon2-cffi-bindings - # cryptography -charset-normalizer==3.4.7 - # via requests -click==8.4.1 - # via - # flask - # rq -comm==0.2.3 - # via ipykernel -contourpy==1.3.3 - # via matplotlib -cramjam==2.11.0 - # via python-snappy -crontab==1.0.5 - # via rq-scheduler -cryptography==48.0.0 - # via pyopenssl -css-html-js-minify==2.5.5 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -cycler==0.12.1 - # via matplotlib -dateparser==1.4.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -ddtrace==4.3.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -debugpy==1.8.21 - # via ipykernel -decorator==5.3.1 - # via ipython -defusedxml==0.7.1 - # via nbconvert -dnspython==2.8.0 - # via - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # pymongo -entrypoints==0.4 - # via jupyter-client -envier==0.6.1 - # via ddtrace -executing==2.2.1 - # via stack-data -fastjsonschema==2.21.2 - # via nbformat -fastnumbers==5.1.1 - # via flask-mongorest-mpcontribs -filetype==1.2.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flasgger-tschaume==0.9.7 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flask==2.2.5 - # via - # flasgger-tschaume - # flask-compress - # flask-marshmallow - # flask-mongoengine-tschaume - # flask-rq2 - # flask-sse -flask-compress==1.24 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flask-marshmallow==1.4.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flask-mongoengine-tschaume==1.1.0 - # via flask-mongorest-mpcontribs -flask-mongorest-mpcontribs==3.3.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flask-rq2==18.3 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -flask-sse==1.0.0 - # via flask-mongorest-mpcontribs -flatten-dict==0.5.0 - # via flask-mongorest-mpcontribs -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -freezegun==1.5.5 - # via rq-scheduler -gevent==26.5.0 - # via gunicorn -greenlet==3.5.1 - # via gevent -gunicorn[gevent]==24.1.1 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -idna==3.18 - # via - # anyio - # jsonschema - # requests -ipykernel==6.29.5 - # via - # nbclassic - # notebook -ipython==9.14.1 - # via ipykernel -ipython-genutils==0.2.0 - # via - # nbclassic - # notebook -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jedi==0.20.0 - # via ipython -jinja2==3.1.6 - # via - # flask - # jupyter-server - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # nbconvert - # notebook -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -json2html==1.3.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonschema[format-nongpl]==4.26.0 - # via - # flasgger-tschaume - # jupyter-events - # nbformat -jsonschema-specifications==2025.9.1 - # via jsonschema -jupyter-client==7.4.9 - # via - # ipykernel - # jupyter-server - # nbclient - # notebook -jupyter-core==5.9.1 - # via - # ipykernel - # jupyter-client - # jupyter-server - # nbclient - # nbconvert - # nbformat - # notebook -jupyter-events==0.12.1 - # via jupyter-server -jupyter-server==2.19.0 - # via notebook-shim -jupyter-server-terminals==0.5.4 - # via jupyter-server -jupyterlab-pygments==0.3.0 - # via nbconvert -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -lxml==6.1.1 - # via pymatgen-core -markupsafe==3.0.3 - # via - # jinja2 - # nbconvert - # werkzeug -marshmallow==3.26.2 - # via - # flask-marshmallow - # marshmallow-mongoengine - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -marshmallow-mongoengine==0.31.2 - # via flask-mongorest-mpcontribs -matplotlib==3.10.9 - # via - # -r python/requirements.txt - # pymatgen-core -matplotlib-inline==0.2.2 - # via - # ipykernel - # ipython -mimerender-pr36==0.0.2 - # via flask-mongorest-mpcontribs -mistune==3.2.1 - # via - # flasgger-tschaume - # nbconvert -mongoengine==0.29.3 - # via - # atlasq-tschaume - # flask-mongoengine-tschaume - # marshmallow-mongoengine -monty==2026.5.18 - # via pymatgen-core -more-itertools==11.1.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -mpmath==1.3.0 - # via sympy -narwhals==2.22.1 - # via plotly -nbclassic==1.3.3 - # via notebook -nbclient==0.11.0 - # via nbconvert -nbconvert==7.17.1 - # via - # jupyter-server - # notebook -nbformat==5.10.4 - # via - # jupyter-server - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # nbclient - # nbconvert - # notebook -nest-asyncio==1.6.0 - # via - # ipykernel - # jupyter-client - # nbclassic - # notebook -networkx==3.6.1 - # via pymatgen-core -notebook==6.5.7 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -notebook-shim==0.2.4 - # via nbclassic -numpy==2.4.6 - # via - # -r python/requirements.txt - # contourpy - # matplotlib - # monty - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # pandas - # pymatgen-core - # scipy - # spglib -opentelemetry-api==1.42.1 - # via ddtrace -orjson==3.11.9 - # via - # flask-mongorest-mpcontribs - # pymatgen-core -overrides==7.7.0 - # via jupyter-server -packaging==26.2 - # via - # gunicorn - # ipykernel - # jupyter-events - # jupyter-server - # marshmallow - # matplotlib - # nbconvert - # plotly -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.3 - # via - # -r python/requirements.txt - # pymatgen-core -pandocfilters==1.5.1 - # via nbconvert -parso==0.8.7 - # via jedi -pexpect==4.9.0 - # via ipython -pillow==12.2.0 - # via matplotlib -pint==0.25.3 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -platformdirs==4.10.0 - # via - # jupyter-core - # pint -plotly==6.8.0 - # via pymatgen-core -prometheus-client==0.25.0 - # via - # jupyter-server - # notebook -prompt-toolkit==3.0.52 - # via ipython -psutil==7.2.2 - # via - # ipykernel - # ipython -psycopg2-binary==2.9.12 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -ptyprocess==0.7.0 - # via - # pexpect - # terminado -pure-eval==0.2.3 - # via stack-data -pycparser==3.0 - # via cffi -pygments==2.20.0 - # via - # ipython - # ipython-pygments-lexers - # nbconvert -pymatgen==2026.5.4 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -pymatgen-core==2026.5.18 - # via pymatgen -pymongo==4.17.0 - # via - # flask-mongorest-mpcontribs - # mongoengine -pyopenssl==26.2.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # dateparser - # flask-mongorest-mpcontribs - # freezegun - # jupyter-client - # matplotlib - # pandas - # rq-scheduler -python-json-logger==4.1.0 - # via jupyter-events -python-mimeparse==2.0.0 - # via mimerender-pr36 -python-snappy==0.7.3 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -pytz==2026.2 - # via dateparser -pyyaml==6.0.3 - # via - # flasgger-tschaume - # jupyter-events -pyzmq==27.1.0 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook -redis==8.0.0 - # via - # flask-rq2 - # flask-sse - # rq -referencing==0.37.0 - # via - # jsonschema - # jsonschema-specifications - # jupyter-events -regex==2026.5.9 - # via dateparser -requests==2.34.2 - # via - # atlasq-tschaume - # pymatgen-core -rfc3339-validator==0.1.4 - # via - # jsonschema - # jupyter-events -rfc3986-validator==0.1.1 - # via - # jsonschema - # jupyter-events -rfc3987-syntax==1.1.0 - # via jsonschema -rpds-py==2026.5.1 - # via - # jsonschema - # referencing -rq==2.3.2 - # via - # flask-rq2 - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # rq-scheduler -rq-scheduler==0.14.0 - # via flask-rq2 -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.18.0 - # via boto3 -scipy==1.17.1 - # via - # -r python/requirements.txt - # pymatgen-core -send2trash==2.1.0 - # via - # jupyter-server - # notebook -setproctitle==1.3.7 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -six==1.17.0 - # via - # flasgger-tschaume - # flask-sse - # python-dateutil - # rfc3339-validator -soupsieve==2.8.4 - # via beautifulsoup4 -spglib==2.7.0 - # via pymatgen-core -stack-data==0.6.3 - # via ipython -supervisor==4.3.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -terminado==0.18.1 - # via - # jupyter-server - # jupyter-server-terminals - # notebook -tinycss2==1.5.1 - # via bleach -tornado==6.5.7 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook - # terminado -tqdm==4.68.1 - # via pymatgen-core -traitlets==5.15.1 - # via - # ipykernel - # ipython - # jupyter-client - # jupyter-core - # jupyter-events - # jupyter-server - # matplotlib-inline - # nbclient - # nbconvert - # nbformat - # notebook -typing-extensions==4.15.0 - # via - # anyio - # beautifulsoup4 - # flexcache - # flexparser - # ipython - # opentelemetry-api - # pint - # pyopenssl - # referencing - # spglib -tzdata==2026.2 - # via arrow -tzlocal==5.3.1 - # via dateparser -uncertainties==3.2.3 - # via - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) - # pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.7.0 - # via - # botocore - # requests -wcwidth==0.8.1 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -webencodings==0.5.1 - # via - # bleach - # tinycss2 -websocket-client==1.9.0 - # via - # jupyter-server - # mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) -werkzeug==3.1.8 - # via - # flasgger-tschaume - # flask -wrapt==2.2.1 - # via ddtrace -zope-event==6.2 - # via gevent -zope-interface==8.5 - # via gevent -zstandard==0.25.0 - # via mpcontribs-api (MPContribs/mpcontribs-api/pyproject.toml) diff --git a/mpcontribs-api/requirements/ubuntu-latest_py3.11.txt b/mpcontribs-api/requirements/ubuntu-latest_py3.11.txt deleted file mode 100644 index ae1ff8eee..000000000 --- a/mpcontribs-api/requirements/ubuntu-latest_py3.11.txt +++ /dev/null @@ -1,548 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --output-file=requirements/ubuntu-latest_py3.11.txt -# -anyio==4.13.0 - # via jupyter-server -apispec==5.2.2 - # via mpcontribs-api (pyproject.toml) -argon2-cffi==25.1.0 - # via - # jupyter-server - # notebook -argon2-cffi-bindings==25.1.0 - # via argon2-cffi -arrow==1.4.0 - # via isoduration -asn1crypto==1.5.1 - # via mpcontribs-api (pyproject.toml) -asttokens==3.0.1 - # via stack-data -atlasq-tschaume==0.11.1.dev2 - # via flask-mongorest-mpcontribs -attrs==26.1.0 - # via - # jsonschema - # referencing -backports-zstd==1.5.0 - # via flask-compress -beautifulsoup4==4.14.3 - # via nbconvert -bibtexparser==1.4.4 - # via pymatgen-core -bleach[css]==6.3.0 - # via nbconvert -blinker==1.9.0 - # via mpcontribs-api (pyproject.toml) -boltons==25.0.0 - # via mpcontribs-api (pyproject.toml) -boto3==1.43.9 - # via flask-mongorest-mpcontribs -botocore==1.43.9 - # via - # boto3 - # s3transfer -brotli==1.2.0 - # via flask-compress -bytecode==0.17.0 - # via ddtrace -certifi==2026.4.22 - # via requests -cffi==2.0.0 - # via - # argon2-cffi-bindings - # cryptography -charset-normalizer==3.4.7 - # via requests -click==8.4.0 - # via - # flask - # rq -comm==0.2.3 - # via ipykernel -contourpy==1.3.3 - # via matplotlib -cramjam==2.11.0 - # via python-snappy -crontab==1.0.5 - # via rq-scheduler -cryptography==48.0.0 - # via pyopenssl -css-html-js-minify==2.5.5 - # via mpcontribs-api (pyproject.toml) -cycler==0.12.1 - # via matplotlib -dateparser==1.4.0 - # via mpcontribs-api (pyproject.toml) -ddtrace==4.3.0 - # via mpcontribs-api (pyproject.toml) -debugpy==1.8.20 - # via ipykernel -decorator==5.3.1 - # via ipython -defusedxml==0.7.1 - # via nbconvert -dnspython==2.8.0 - # via - # mpcontribs-api (pyproject.toml) - # pymongo -entrypoints==0.4 - # via jupyter-client -envier==0.6.1 - # via ddtrace -executing==2.2.1 - # via stack-data -fastjsonschema==2.21.2 - # via nbformat -fastnumbers==5.1.1 - # via flask-mongorest-mpcontribs -filetype==1.2.0 - # via mpcontribs-api (pyproject.toml) -flasgger-tschaume==0.9.7 - # via mpcontribs-api (pyproject.toml) -flask==2.2.5 - # via - # flasgger-tschaume - # flask-compress - # flask-marshmallow - # flask-mongoengine-tschaume - # flask-rq2 - # flask-sse -flask-compress==1.24 - # via mpcontribs-api (pyproject.toml) -flask-marshmallow==1.4.0 - # via mpcontribs-api (pyproject.toml) -flask-mongoengine-tschaume==1.1.0 - # via flask-mongorest-mpcontribs -flask-mongorest-mpcontribs==3.3.0 - # via mpcontribs-api (pyproject.toml) -flask-rq2==18.3 - # via mpcontribs-api (pyproject.toml) -flask-sse==1.0.0 - # via flask-mongorest-mpcontribs -flatten-dict==0.5.0 - # via flask-mongorest-mpcontribs -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -freezegun==1.5.5 - # via rq-scheduler -gevent==26.4.0 - # via gunicorn -greenlet==3.5.0 - # via gevent -gunicorn[gevent]==24.1.1 - # via mpcontribs-api (pyproject.toml) -idna==3.15 - # via - # anyio - # jsonschema - # requests -importlib-metadata==8.7.1 - # via opentelemetry-api -ipykernel==6.29.5 - # via - # nbclassic - # notebook -ipython==9.13.0 - # via ipykernel -ipython-genutils==0.2.0 - # via - # nbclassic - # notebook -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jedi==0.20.0 - # via ipython -jinja2==3.1.6 - # via - # flask - # jupyter-server - # mpcontribs-api (pyproject.toml) - # nbconvert - # notebook -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -json2html==1.3.0 - # via mpcontribs-api (pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonschema[format-nongpl]==4.26.0 - # via - # flasgger-tschaume - # jupyter-events - # nbformat -jsonschema-specifications==2025.9.1 - # via jsonschema -jupyter-client==7.4.9 - # via - # ipykernel - # jupyter-server - # nbclient - # notebook -jupyter-core==5.9.1 - # via - # ipykernel - # jupyter-client - # jupyter-server - # nbclient - # nbconvert - # nbformat - # notebook -jupyter-events==0.12.1 - # via jupyter-server -jupyter-server==2.18.2 - # via notebook-shim -jupyter-server-terminals==0.5.4 - # via jupyter-server -jupyterlab-pygments==0.3.0 - # via nbconvert -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -lxml==6.1.0 - # via pymatgen-core -markupsafe==3.0.3 - # via - # jinja2 - # nbconvert - # werkzeug -marshmallow==3.26.2 - # via - # flask-marshmallow - # marshmallow-mongoengine - # mpcontribs-api (pyproject.toml) -marshmallow-mongoengine==0.31.2 - # via flask-mongorest-mpcontribs -matplotlib==3.10.9 - # via pymatgen-core -matplotlib-inline==0.2.2 - # via - # ipykernel - # ipython -mimerender-pr36==0.0.2 - # via flask-mongorest-mpcontribs -mistune==3.2.1 - # via - # flasgger-tschaume - # nbconvert -mongoengine==0.29.3 - # via - # atlasq-tschaume - # flask-mongoengine-tschaume - # marshmallow-mongoengine -monty==2026.5.18 - # via pymatgen-core -more-itertools==11.0.2 - # via mpcontribs-api (pyproject.toml) -mpmath==1.3.0 - # via sympy -narwhals==2.21.2 - # via plotly -nbclassic==1.3.3 - # via notebook -nbclient==0.10.4 - # via nbconvert -nbconvert==7.17.1 - # via - # jupyter-server - # notebook -nbformat==5.10.4 - # via - # jupyter-server - # mpcontribs-api (pyproject.toml) - # nbclient - # nbconvert - # notebook -nest-asyncio==1.6.0 - # via - # ipykernel - # jupyter-client - # nbclassic - # notebook -networkx==3.6.1 - # via pymatgen-core -notebook==6.5.7 - # via mpcontribs-api (pyproject.toml) -notebook-shim==0.2.4 - # via nbclassic -numpy==2.4.5 - # via - # contourpy - # matplotlib - # monty - # mpcontribs-api (pyproject.toml) - # pandas - # pymatgen-core - # scipy - # spglib -opentelemetry-api==1.41.1 - # via ddtrace -orjson==3.11.9 - # via - # flask-mongorest-mpcontribs - # pymatgen-core -overrides==7.7.0 - # via jupyter-server -packaging==26.2 - # via - # gunicorn - # ipykernel - # jupyter-events - # jupyter-server - # marshmallow - # matplotlib - # nbconvert - # plotly -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.3 - # via pymatgen-core -pandocfilters==1.5.1 - # via nbconvert -parso==0.8.7 - # via jedi -pexpect==4.9.0 - # via ipython -pillow==12.2.0 - # via matplotlib -pint==0.25.3 - # via mpcontribs-api (pyproject.toml) -platformdirs==4.9.6 - # via - # jupyter-core - # pint -plotly==6.7.0 - # via pymatgen-core -prometheus-client==0.25.0 - # via - # jupyter-server - # notebook -prompt-toolkit==3.0.52 - # via ipython -psutil==7.2.2 - # via - # ipykernel - # ipython -psycopg2-binary==2.9.12 - # via mpcontribs-api (pyproject.toml) -ptyprocess==0.7.0 - # via - # pexpect - # terminado -pure-eval==0.2.3 - # via stack-data -pycparser==3.0 - # via cffi -pygments==2.20.0 - # via - # ipython - # ipython-pygments-lexers - # nbconvert -pymatgen==2026.5.4 - # via mpcontribs-api (pyproject.toml) -pymatgen-core==2026.5.17 - # via pymatgen -pymongo==4.17.0 - # via - # flask-mongorest-mpcontribs - # mongoengine -pyopenssl==26.2.0 - # via mpcontribs-api (pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # dateparser - # flask-mongorest-mpcontribs - # freezegun - # jupyter-client - # matplotlib - # pandas - # rq-scheduler -python-json-logger==4.1.0 - # via jupyter-events -python-mimeparse==2.0.0 - # via mimerender-pr36 -python-snappy==0.7.3 - # via mpcontribs-api (pyproject.toml) -pytz==2026.2 - # via dateparser -pyyaml==6.0.3 - # via - # flasgger-tschaume - # jupyter-events -pyzmq==27.1.0 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook -redis==7.4.0 - # via - # flask-rq2 - # flask-sse - # rq -referencing==0.37.0 - # via - # jsonschema - # jsonschema-specifications - # jupyter-events -regex==2026.5.9 - # via dateparser -requests==2.34.2 - # via - # atlasq-tschaume - # pymatgen-core -rfc3339-validator==0.1.4 - # via - # jsonschema - # jupyter-events -rfc3986-validator==0.1.1 - # via - # jsonschema - # jupyter-events -rfc3987-syntax==1.1.0 - # via jsonschema -rpds-py==0.30.0 - # via - # jsonschema - # referencing -rq==2.3.2 - # via - # flask-rq2 - # mpcontribs-api (pyproject.toml) - # rq-scheduler -rq-scheduler==0.14.0 - # via flask-rq2 -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.17.0 - # via boto3 -scipy==1.17.1 - # via pymatgen-core -send2trash==2.1.0 - # via - # jupyter-server - # notebook -setproctitle==1.3.7 - # via mpcontribs-api (pyproject.toml) -six==1.17.0 - # via - # flasgger-tschaume - # flask-sse - # python-dateutil - # rfc3339-validator -soupsieve==2.8.3 - # via beautifulsoup4 -spglib==2.7.0 - # via pymatgen-core -stack-data==0.6.3 - # via ipython -supervisor==4.3.0 - # via mpcontribs-api (pyproject.toml) -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -terminado==0.18.1 - # via - # jupyter-server - # jupyter-server-terminals - # notebook -tinycss2==1.4.0 - # via bleach -tornado==6.5.5 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook - # terminado -tqdm==4.67.3 - # via pymatgen-core -traitlets==5.15.0 - # via - # ipykernel - # ipython - # jupyter-client - # jupyter-core - # jupyter-events - # jupyter-server - # matplotlib-inline - # nbclient - # nbconvert - # nbformat - # notebook -typing-extensions==4.15.0 - # via - # anyio - # beautifulsoup4 - # flexcache - # flexparser - # ipython - # opentelemetry-api - # pint - # pyopenssl - # referencing - # spglib -tzdata==2026.2 - # via arrow -tzlocal==5.3.1 - # via dateparser -uncertainties==3.2.3 - # via - # mpcontribs-api (pyproject.toml) - # pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.7.0 - # via - # botocore - # requests -wcwidth==0.7.0 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -webencodings==0.5.1 - # via - # bleach - # tinycss2 -websocket-client==1.9.0 - # via - # jupyter-server - # mpcontribs-api (pyproject.toml) -werkzeug==3.1.8 - # via - # flasgger-tschaume - # flask -wrapt==2.1.2 - # via ddtrace -zipp==3.23.1 - # via importlib-metadata -zope-event==6.2 - # via gevent -zope-interface==8.4 - # via gevent -zstandard==0.25.0 - # via mpcontribs-api (pyproject.toml) diff --git a/mpcontribs-api/requirements/ubuntu-latest_py3.11_extras.txt b/mpcontribs-api/requirements/ubuntu-latest_py3.11_extras.txt deleted file mode 100644 index 6387c63ec..000000000 --- a/mpcontribs-api/requirements/ubuntu-latest_py3.11_extras.txt +++ /dev/null @@ -1,648 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --all-extras --output-file=requirements/ubuntu-latest_py3.11_extras.txt -# -anyio==4.13.0 - # via jupyter-server -apispec==5.2.2 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -argon2-cffi==25.1.0 - # via - # jupyter-server - # notebook -argon2-cffi-bindings==25.1.0 - # via argon2-cffi -arrow==1.4.0 - # via isoduration -asn1crypto==1.5.1 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -asttokens==3.0.1 - # via stack-data -atlasq-tschaume==0.11.1.dev2 - # via flask-mongorest-mpcontribs -attrs==26.1.0 - # via - # jsonschema - # referencing -backports-zstd==1.5.0 - # via flask-compress -beautifulsoup4==4.14.3 - # via nbconvert -bibtexparser==1.4.4 - # via pymatgen-core -bleach[css]==6.3.0 - # via nbconvert -blinker==1.9.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -boltons==25.0.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -boto3==1.43.9 - # via flask-mongorest-mpcontribs -botocore==1.43.9 - # via - # boto3 - # s3transfer -brotli==1.2.0 - # via flask-compress -bytecode==0.17.0 - # via ddtrace -certifi==2026.4.22 - # via requests -cffi==2.0.0 - # via - # argon2-cffi-bindings - # cryptography -charset-normalizer==3.4.7 - # via requests -click==8.4.0 - # via - # flask - # rq -comm==0.2.3 - # via ipykernel -contourpy==1.3.3 - # via matplotlib -cramjam==2.11.0 - # via python-snappy -crontab==1.0.5 - # via rq-scheduler -cryptography==48.0.0 - # via pyopenssl -css-html-js-minify==2.5.5 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -cycler==0.12.1 - # via matplotlib -dateparser==1.4.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -ddtrace==4.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -debugpy==1.8.20 - # via ipykernel -decorator==5.3.1 - # via ipython -defusedxml==0.7.1 - # via nbconvert -dnspython==2.8.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pymongo -entrypoints==0.4 - # via jupyter-client -envier==0.6.1 - # via ddtrace -execnet==2.1.2 - # via pytest-xdist -executing==2.2.1 - # via stack-data -fastjsonschema==2.21.2 - # via nbformat -fastnumbers==5.1.1 - # via flask-mongorest-mpcontribs -filetype==1.2.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flake8==7.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pytest-flake8 -flasgger-tschaume==0.9.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask==2.2.5 - # via - # flasgger-tschaume - # flask-compress - # flask-marshmallow - # flask-mongoengine-tschaume - # flask-rq2 - # flask-sse -flask-compress==1.24 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-marshmallow==1.4.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-mongoengine-tschaume==1.1.0 - # via flask-mongorest-mpcontribs -flask-mongorest-mpcontribs==3.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-rq2==18.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-sse==1.0.0 - # via flask-mongorest-mpcontribs -flatten-dict==0.5.0 - # via flask-mongorest-mpcontribs -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -freezegun==1.5.5 - # via rq-scheduler -gevent==26.4.0 - # via gunicorn -greenlet==3.5.0 - # via gevent -gunicorn[gevent]==24.1.1 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -idna==3.15 - # via - # anyio - # jsonschema - # requests -importlib-metadata==8.7.1 - # via opentelemetry-api -iniconfig==2.3.0 - # via pytest -ipykernel==6.29.5 - # via - # nbclassic - # notebook -ipython==9.13.0 - # via ipykernel -ipython-genutils==0.2.0 - # via - # nbclassic - # notebook -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jedi==0.20.0 - # via ipython -jinja2==3.1.6 - # via - # flask - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # nbconvert - # notebook -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -json2html==1.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonschema[format-nongpl]==4.26.0 - # via - # flasgger-tschaume - # jupyter-events - # nbformat -jsonschema-specifications==2025.9.1 - # via jsonschema -jupyter-client==7.4.9 - # via - # ipykernel - # jupyter-server - # nbclient - # notebook -jupyter-core==5.9.1 - # via - # ipykernel - # jupyter-client - # jupyter-server - # nbclient - # nbconvert - # nbformat - # notebook -jupyter-events==0.12.1 - # via jupyter-server -jupyter-server==2.18.2 - # via notebook-shim -jupyter-server-terminals==0.5.4 - # via jupyter-server -jupyterlab-pygments==0.3.0 - # via nbconvert -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -lxml==6.1.0 - # via pymatgen-core -markupsafe==3.0.3 - # via - # jinja2 - # nbconvert - # werkzeug -marshmallow==3.26.2 - # via - # flask-marshmallow - # marshmallow-mongoengine - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -marshmallow-mongoengine==0.31.2 - # via flask-mongorest-mpcontribs -matplotlib==3.10.9 - # via pymatgen-core -matplotlib-inline==0.2.2 - # via - # ipykernel - # ipython -mccabe==0.7.0 - # via flake8 -mimerender-pr36==0.0.2 - # via flask-mongorest-mpcontribs -mistune==3.2.1 - # via - # flasgger-tschaume - # nbconvert -mongoengine==0.29.3 - # via - # atlasq-tschaume - # flask-mongoengine-tschaume - # marshmallow-mongoengine -monty==2026.5.18 - # via pymatgen-core -more-itertools==11.0.2 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -mpcontribs-api[dev] @ file:///home/runner/work/MPContribs/MPContribs/mpcontribs-api - # via mpcontribs-api (pyproject.toml) -mpmath==1.3.0 - # via sympy -narwhals==2.21.2 - # via plotly -nbclassic==1.3.3 - # via notebook -nbclient==0.10.4 - # via nbconvert -nbconvert==7.17.1 - # via - # jupyter-server - # notebook -nbformat==5.10.4 - # via - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # nbclient - # nbconvert - # notebook -nest-asyncio==1.6.0 - # via - # ipykernel - # jupyter-client - # nbclassic - # notebook -networkx==3.6.1 - # via pymatgen-core -notebook==6.5.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -notebook-shim==0.2.4 - # via nbclassic -numpy==2.4.5 - # via - # contourpy - # matplotlib - # monty - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pandas - # pymatgen-core - # scipy - # spglib -opentelemetry-api==1.41.1 - # via ddtrace -orjson==3.11.9 - # via - # flask-mongorest-mpcontribs - # pymatgen-core -overrides==7.7.0 - # via jupyter-server -packaging==26.2 - # via - # gunicorn - # ipykernel - # jupyter-events - # jupyter-server - # marshmallow - # matplotlib - # nbconvert - # plotly - # pytest -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.3 - # via pymatgen-core -pandocfilters==1.5.1 - # via nbconvert -parso==0.8.7 - # via jedi -pexpect==4.9.0 - # via ipython -pillow==12.2.0 - # via matplotlib -pint==0.25.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -platformdirs==4.9.6 - # via - # jupyter-core - # pint -plotly==6.7.0 - # via pymatgen-core -pluggy==1.6.0 - # via pytest -prometheus-client==0.25.0 - # via - # jupyter-server - # notebook -prompt-toolkit==3.0.52 - # via ipython -psutil==7.2.2 - # via - # ipykernel - # ipython -psycopg2-binary==2.9.12 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -ptyprocess==0.7.0 - # via - # pexpect - # terminado -pure-eval==0.2.3 - # via stack-data -pycodestyle==2.14.0 - # via - # flake8 - # pytest-pycodestyle -pycparser==3.0 - # via cffi -pyflakes==3.4.0 - # via flake8 -pygments==2.20.0 - # via - # ipython - # ipython-pygments-lexers - # nbconvert - # pytest -pymatgen==2026.5.4 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pymatgen-core==2026.5.17 - # via pymatgen -pymongo==4.17.0 - # via - # flask-mongorest-mpcontribs - # mongoengine -pyopenssl==26.2.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -pytest==9.0.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pytest-flake8 - # pytest-pycodestyle - # pytest-xdist -pytest-flake8==1.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytest-pycodestyle==2.5.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytest-xdist==3.8.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # dateparser - # flask-mongorest-mpcontribs - # freezegun - # jupyter-client - # matplotlib - # pandas - # rq-scheduler -python-json-logger==4.1.0 - # via jupyter-events -python-mimeparse==2.0.0 - # via mimerender-pr36 -python-snappy==0.7.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytz==2026.2 - # via dateparser -pyyaml==6.0.3 - # via - # flasgger-tschaume - # jupyter-events -pyzmq==27.1.0 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook -redis==7.4.0 - # via - # flask-rq2 - # flask-sse - # rq -referencing==0.37.0 - # via - # jsonschema - # jsonschema-specifications - # jupyter-events -regex==2026.5.9 - # via dateparser -requests==2.34.2 - # via - # atlasq-tschaume - # pymatgen-core -rfc3339-validator==0.1.4 - # via - # jsonschema - # jupyter-events -rfc3986-validator==0.1.1 - # via - # jsonschema - # jupyter-events -rfc3987-syntax==1.1.0 - # via jsonschema -rpds-py==0.30.0 - # via - # jsonschema - # referencing -rq==2.3.2 - # via - # flask-rq2 - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # rq-scheduler -rq-scheduler==0.14.0 - # via flask-rq2 -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.17.0 - # via boto3 -scipy==1.17.1 - # via pymatgen-core -send2trash==2.1.0 - # via - # jupyter-server - # notebook -setproctitle==1.3.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -six==1.17.0 - # via - # flasgger-tschaume - # flask-sse - # python-dateutil - # rfc3339-validator -soupsieve==2.8.3 - # via beautifulsoup4 -spglib==2.7.0 - # via pymatgen-core -stack-data==0.6.3 - # via ipython -supervisor==4.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -terminado==0.18.1 - # via - # jupyter-server - # jupyter-server-terminals - # notebook -tinycss2==1.4.0 - # via bleach -tornado==6.5.5 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook - # terminado -tqdm==4.67.3 - # via pymatgen-core -traitlets==5.15.0 - # via - # ipykernel - # ipython - # jupyter-client - # jupyter-core - # jupyter-events - # jupyter-server - # matplotlib-inline - # nbclient - # nbconvert - # nbformat - # notebook -typing-extensions==4.15.0 - # via - # anyio - # beautifulsoup4 - # flexcache - # flexparser - # ipython - # opentelemetry-api - # pint - # pyopenssl - # referencing - # spglib -tzdata==2026.2 - # via arrow -tzlocal==5.3.1 - # via dateparser -uncertainties==3.2.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.7.0 - # via - # botocore - # requests -wcwidth==0.7.0 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -webencodings==0.5.1 - # via - # bleach - # tinycss2 -websocket-client==1.9.0 - # via - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -werkzeug==3.1.8 - # via - # flasgger-tschaume - # flask -wrapt==2.1.2 - # via ddtrace -zipp==3.23.1 - # via importlib-metadata -zope-event==6.2 - # via gevent -zope-interface==8.4 - # via gevent -zstandard==0.25.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) diff --git a/mpcontribs-api/requirements/ubuntu-latest_py3.12.txt b/mpcontribs-api/requirements/ubuntu-latest_py3.12.txt deleted file mode 100644 index e9a48b299..000000000 --- a/mpcontribs-api/requirements/ubuntu-latest_py3.12.txt +++ /dev/null @@ -1,545 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --output-file=requirements/ubuntu-latest_py3.12.txt -# -anyio==4.13.0 - # via jupyter-server -apispec==5.2.2 - # via mpcontribs-api (pyproject.toml) -argon2-cffi==25.1.0 - # via - # jupyter-server - # notebook -argon2-cffi-bindings==25.1.0 - # via argon2-cffi -arrow==1.4.0 - # via isoduration -asn1crypto==1.5.1 - # via mpcontribs-api (pyproject.toml) -asttokens==3.0.1 - # via stack-data -atlasq-tschaume==0.11.1.dev2 - # via flask-mongorest-mpcontribs -attrs==26.1.0 - # via - # jsonschema - # referencing -backports-zstd==1.5.0 - # via flask-compress -beautifulsoup4==4.14.3 - # via nbconvert -bibtexparser==1.4.4 - # via pymatgen-core -bleach[css]==6.3.0 - # via nbconvert -blinker==1.9.0 - # via mpcontribs-api (pyproject.toml) -boltons==25.0.0 - # via mpcontribs-api (pyproject.toml) -boto3==1.43.9 - # via flask-mongorest-mpcontribs -botocore==1.43.9 - # via - # boto3 - # s3transfer -brotli==1.2.0 - # via flask-compress -bytecode==0.17.0 - # via ddtrace -certifi==2026.4.22 - # via requests -cffi==2.0.0 - # via - # argon2-cffi-bindings - # cryptography -charset-normalizer==3.4.7 - # via requests -click==8.4.0 - # via - # flask - # rq -comm==0.2.3 - # via ipykernel -contourpy==1.3.3 - # via matplotlib -cramjam==2.11.0 - # via python-snappy -crontab==1.0.5 - # via rq-scheduler -cryptography==48.0.0 - # via pyopenssl -css-html-js-minify==2.5.5 - # via mpcontribs-api (pyproject.toml) -cycler==0.12.1 - # via matplotlib -dateparser==1.4.0 - # via mpcontribs-api (pyproject.toml) -ddtrace==4.3.0 - # via mpcontribs-api (pyproject.toml) -debugpy==1.8.20 - # via ipykernel -decorator==5.3.1 - # via ipython -defusedxml==0.7.1 - # via nbconvert -dnspython==2.8.0 - # via - # mpcontribs-api (pyproject.toml) - # pymongo -entrypoints==0.4 - # via jupyter-client -envier==0.6.1 - # via ddtrace -executing==2.2.1 - # via stack-data -fastjsonschema==2.21.2 - # via nbformat -fastnumbers==5.1.1 - # via flask-mongorest-mpcontribs -filetype==1.2.0 - # via mpcontribs-api (pyproject.toml) -flasgger-tschaume==0.9.7 - # via mpcontribs-api (pyproject.toml) -flask==2.2.5 - # via - # flasgger-tschaume - # flask-compress - # flask-marshmallow - # flask-mongoengine-tschaume - # flask-rq2 - # flask-sse -flask-compress==1.24 - # via mpcontribs-api (pyproject.toml) -flask-marshmallow==1.4.0 - # via mpcontribs-api (pyproject.toml) -flask-mongoengine-tschaume==1.1.0 - # via flask-mongorest-mpcontribs -flask-mongorest-mpcontribs==3.3.0 - # via mpcontribs-api (pyproject.toml) -flask-rq2==18.3 - # via mpcontribs-api (pyproject.toml) -flask-sse==1.0.0 - # via flask-mongorest-mpcontribs -flatten-dict==0.5.0 - # via flask-mongorest-mpcontribs -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -freezegun==1.5.5 - # via rq-scheduler -gevent==26.4.0 - # via gunicorn -greenlet==3.5.0 - # via gevent -gunicorn[gevent]==24.1.1 - # via mpcontribs-api (pyproject.toml) -idna==3.15 - # via - # anyio - # jsonschema - # requests -importlib-metadata==8.7.1 - # via opentelemetry-api -ipykernel==6.29.5 - # via - # nbclassic - # notebook -ipython==9.13.0 - # via ipykernel -ipython-genutils==0.2.0 - # via - # nbclassic - # notebook -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jedi==0.20.0 - # via ipython -jinja2==3.1.6 - # via - # flask - # jupyter-server - # mpcontribs-api (pyproject.toml) - # nbconvert - # notebook -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -json2html==1.3.0 - # via mpcontribs-api (pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonschema[format-nongpl]==4.26.0 - # via - # flasgger-tschaume - # jupyter-events - # nbformat -jsonschema-specifications==2025.9.1 - # via jsonschema -jupyter-client==7.4.9 - # via - # ipykernel - # jupyter-server - # nbclient - # notebook -jupyter-core==5.9.1 - # via - # ipykernel - # jupyter-client - # jupyter-server - # nbclient - # nbconvert - # nbformat - # notebook -jupyter-events==0.12.1 - # via jupyter-server -jupyter-server==2.18.2 - # via notebook-shim -jupyter-server-terminals==0.5.4 - # via jupyter-server -jupyterlab-pygments==0.3.0 - # via nbconvert -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -lxml==6.1.0 - # via pymatgen-core -markupsafe==3.0.3 - # via - # jinja2 - # nbconvert - # werkzeug -marshmallow==3.26.2 - # via - # flask-marshmallow - # marshmallow-mongoengine - # mpcontribs-api (pyproject.toml) -marshmallow-mongoengine==0.31.2 - # via flask-mongorest-mpcontribs -matplotlib==3.10.9 - # via pymatgen-core -matplotlib-inline==0.2.2 - # via - # ipykernel - # ipython -mimerender-pr36==0.0.2 - # via flask-mongorest-mpcontribs -mistune==3.2.1 - # via - # flasgger-tschaume - # nbconvert -mongoengine==0.29.3 - # via - # atlasq-tschaume - # flask-mongoengine-tschaume - # marshmallow-mongoengine -monty==2026.5.18 - # via pymatgen-core -more-itertools==11.0.2 - # via mpcontribs-api (pyproject.toml) -mpmath==1.3.0 - # via sympy -narwhals==2.21.2 - # via plotly -nbclassic==1.3.3 - # via notebook -nbclient==0.10.4 - # via nbconvert -nbconvert==7.17.1 - # via - # jupyter-server - # notebook -nbformat==5.10.4 - # via - # jupyter-server - # mpcontribs-api (pyproject.toml) - # nbclient - # nbconvert - # notebook -nest-asyncio==1.6.0 - # via - # ipykernel - # jupyter-client - # nbclassic - # notebook -networkx==3.6.1 - # via pymatgen-core -notebook==6.5.7 - # via mpcontribs-api (pyproject.toml) -notebook-shim==0.2.4 - # via nbclassic -numpy==2.4.5 - # via - # contourpy - # matplotlib - # monty - # mpcontribs-api (pyproject.toml) - # pandas - # pymatgen-core - # scipy - # spglib -opentelemetry-api==1.41.1 - # via ddtrace -orjson==3.11.9 - # via - # flask-mongorest-mpcontribs - # pymatgen-core -packaging==26.2 - # via - # gunicorn - # ipykernel - # jupyter-events - # jupyter-server - # marshmallow - # matplotlib - # nbconvert - # plotly -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.3 - # via pymatgen-core -pandocfilters==1.5.1 - # via nbconvert -parso==0.8.7 - # via jedi -pexpect==4.9.0 - # via ipython -pillow==12.2.0 - # via matplotlib -pint==0.25.3 - # via mpcontribs-api (pyproject.toml) -platformdirs==4.9.6 - # via - # jupyter-core - # pint -plotly==6.7.0 - # via pymatgen-core -prometheus-client==0.25.0 - # via - # jupyter-server - # notebook -prompt-toolkit==3.0.52 - # via ipython -psutil==7.2.2 - # via - # ipykernel - # ipython -psycopg2-binary==2.9.12 - # via mpcontribs-api (pyproject.toml) -ptyprocess==0.7.0 - # via - # pexpect - # terminado -pure-eval==0.2.3 - # via stack-data -pycparser==3.0 - # via cffi -pygments==2.20.0 - # via - # ipython - # ipython-pygments-lexers - # nbconvert -pymatgen==2026.5.4 - # via mpcontribs-api (pyproject.toml) -pymatgen-core==2026.5.17 - # via pymatgen -pymongo==4.17.0 - # via - # flask-mongorest-mpcontribs - # mongoengine -pyopenssl==26.2.0 - # via mpcontribs-api (pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # dateparser - # flask-mongorest-mpcontribs - # freezegun - # jupyter-client - # matplotlib - # pandas - # rq-scheduler -python-json-logger==4.1.0 - # via jupyter-events -python-mimeparse==2.0.0 - # via mimerender-pr36 -python-snappy==0.7.3 - # via mpcontribs-api (pyproject.toml) -pytz==2026.2 - # via dateparser -pyyaml==6.0.3 - # via - # flasgger-tschaume - # jupyter-events -pyzmq==27.1.0 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook -redis==7.4.0 - # via - # flask-rq2 - # flask-sse - # rq -referencing==0.37.0 - # via - # jsonschema - # jsonschema-specifications - # jupyter-events -regex==2026.5.9 - # via dateparser -requests==2.34.2 - # via - # atlasq-tschaume - # pymatgen-core -rfc3339-validator==0.1.4 - # via - # jsonschema - # jupyter-events -rfc3986-validator==0.1.1 - # via - # jsonschema - # jupyter-events -rfc3987-syntax==1.1.0 - # via jsonschema -rpds-py==0.30.0 - # via - # jsonschema - # referencing -rq==2.3.2 - # via - # flask-rq2 - # mpcontribs-api (pyproject.toml) - # rq-scheduler -rq-scheduler==0.14.0 - # via flask-rq2 -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.17.0 - # via boto3 -scipy==1.17.1 - # via pymatgen-core -send2trash==2.1.0 - # via - # jupyter-server - # notebook -setproctitle==1.3.7 - # via mpcontribs-api (pyproject.toml) -six==1.17.0 - # via - # flasgger-tschaume - # flask-sse - # python-dateutil - # rfc3339-validator -soupsieve==2.8.3 - # via beautifulsoup4 -spglib==2.7.0 - # via pymatgen-core -stack-data==0.6.3 - # via ipython -supervisor==4.3.0 - # via mpcontribs-api (pyproject.toml) -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -terminado==0.18.1 - # via - # jupyter-server - # jupyter-server-terminals - # notebook -tinycss2==1.4.0 - # via bleach -tornado==6.5.5 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook - # terminado -tqdm==4.67.3 - # via pymatgen-core -traitlets==5.15.0 - # via - # ipykernel - # ipython - # jupyter-client - # jupyter-core - # jupyter-events - # jupyter-server - # matplotlib-inline - # nbclient - # nbconvert - # nbformat - # notebook -typing-extensions==4.15.0 - # via - # anyio - # beautifulsoup4 - # flexcache - # flexparser - # opentelemetry-api - # pint - # pyopenssl - # referencing - # spglib -tzdata==2026.2 - # via arrow -tzlocal==5.3.1 - # via dateparser -uncertainties==3.2.3 - # via - # mpcontribs-api (pyproject.toml) - # pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.7.0 - # via - # botocore - # requests -wcwidth==0.7.0 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -webencodings==0.5.1 - # via - # bleach - # tinycss2 -websocket-client==1.9.0 - # via - # jupyter-server - # mpcontribs-api (pyproject.toml) -werkzeug==3.1.8 - # via - # flasgger-tschaume - # flask -wrapt==2.1.2 - # via ddtrace -zipp==3.23.1 - # via importlib-metadata -zope-event==6.2 - # via gevent -zope-interface==8.4 - # via gevent -zstandard==0.25.0 - # via mpcontribs-api (pyproject.toml) diff --git a/mpcontribs-api/requirements/ubuntu-latest_py3.12_extras.txt b/mpcontribs-api/requirements/ubuntu-latest_py3.12_extras.txt deleted file mode 100644 index cd894fd1d..000000000 --- a/mpcontribs-api/requirements/ubuntu-latest_py3.12_extras.txt +++ /dev/null @@ -1,645 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --all-extras --output-file=requirements/ubuntu-latest_py3.12_extras.txt -# -anyio==4.13.0 - # via jupyter-server -apispec==5.2.2 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -argon2-cffi==25.1.0 - # via - # jupyter-server - # notebook -argon2-cffi-bindings==25.1.0 - # via argon2-cffi -arrow==1.4.0 - # via isoduration -asn1crypto==1.5.1 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -asttokens==3.0.1 - # via stack-data -atlasq-tschaume==0.11.1.dev2 - # via flask-mongorest-mpcontribs -attrs==26.1.0 - # via - # jsonschema - # referencing -backports-zstd==1.5.0 - # via flask-compress -beautifulsoup4==4.14.3 - # via nbconvert -bibtexparser==1.4.4 - # via pymatgen-core -bleach[css]==6.3.0 - # via nbconvert -blinker==1.9.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -boltons==25.0.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -boto3==1.43.9 - # via flask-mongorest-mpcontribs -botocore==1.43.9 - # via - # boto3 - # s3transfer -brotli==1.2.0 - # via flask-compress -bytecode==0.17.0 - # via ddtrace -certifi==2026.4.22 - # via requests -cffi==2.0.0 - # via - # argon2-cffi-bindings - # cryptography -charset-normalizer==3.4.7 - # via requests -click==8.4.0 - # via - # flask - # rq -comm==0.2.3 - # via ipykernel -contourpy==1.3.3 - # via matplotlib -cramjam==2.11.0 - # via python-snappy -crontab==1.0.5 - # via rq-scheduler -cryptography==48.0.0 - # via pyopenssl -css-html-js-minify==2.5.5 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -cycler==0.12.1 - # via matplotlib -dateparser==1.4.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -ddtrace==4.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -debugpy==1.8.20 - # via ipykernel -decorator==5.3.1 - # via ipython -defusedxml==0.7.1 - # via nbconvert -dnspython==2.8.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pymongo -entrypoints==0.4 - # via jupyter-client -envier==0.6.1 - # via ddtrace -execnet==2.1.2 - # via pytest-xdist -executing==2.2.1 - # via stack-data -fastjsonschema==2.21.2 - # via nbformat -fastnumbers==5.1.1 - # via flask-mongorest-mpcontribs -filetype==1.2.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flake8==7.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pytest-flake8 -flasgger-tschaume==0.9.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask==2.2.5 - # via - # flasgger-tschaume - # flask-compress - # flask-marshmallow - # flask-mongoengine-tschaume - # flask-rq2 - # flask-sse -flask-compress==1.24 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-marshmallow==1.4.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-mongoengine-tschaume==1.1.0 - # via flask-mongorest-mpcontribs -flask-mongorest-mpcontribs==3.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-rq2==18.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -flask-sse==1.0.0 - # via flask-mongorest-mpcontribs -flatten-dict==0.5.0 - # via flask-mongorest-mpcontribs -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -freezegun==1.5.5 - # via rq-scheduler -gevent==26.4.0 - # via gunicorn -greenlet==3.5.0 - # via gevent -gunicorn[gevent]==24.1.1 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -idna==3.15 - # via - # anyio - # jsonschema - # requests -importlib-metadata==8.7.1 - # via opentelemetry-api -iniconfig==2.3.0 - # via pytest -ipykernel==6.29.5 - # via - # nbclassic - # notebook -ipython==9.13.0 - # via ipykernel -ipython-genutils==0.2.0 - # via - # nbclassic - # notebook -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jedi==0.20.0 - # via ipython -jinja2==3.1.6 - # via - # flask - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # nbconvert - # notebook -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -json2html==1.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonschema[format-nongpl]==4.26.0 - # via - # flasgger-tschaume - # jupyter-events - # nbformat -jsonschema-specifications==2025.9.1 - # via jsonschema -jupyter-client==7.4.9 - # via - # ipykernel - # jupyter-server - # nbclient - # notebook -jupyter-core==5.9.1 - # via - # ipykernel - # jupyter-client - # jupyter-server - # nbclient - # nbconvert - # nbformat - # notebook -jupyter-events==0.12.1 - # via jupyter-server -jupyter-server==2.18.2 - # via notebook-shim -jupyter-server-terminals==0.5.4 - # via jupyter-server -jupyterlab-pygments==0.3.0 - # via nbconvert -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -lxml==6.1.0 - # via pymatgen-core -markupsafe==3.0.3 - # via - # jinja2 - # nbconvert - # werkzeug -marshmallow==3.26.2 - # via - # flask-marshmallow - # marshmallow-mongoengine - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -marshmallow-mongoengine==0.31.2 - # via flask-mongorest-mpcontribs -matplotlib==3.10.9 - # via pymatgen-core -matplotlib-inline==0.2.2 - # via - # ipykernel - # ipython -mccabe==0.7.0 - # via flake8 -mimerender-pr36==0.0.2 - # via flask-mongorest-mpcontribs -mistune==3.2.1 - # via - # flasgger-tschaume - # nbconvert -mongoengine==0.29.3 - # via - # atlasq-tschaume - # flask-mongoengine-tschaume - # marshmallow-mongoengine -monty==2026.5.18 - # via pymatgen-core -more-itertools==11.0.2 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -mpcontribs-api[dev] @ file:///home/runner/work/MPContribs/MPContribs/mpcontribs-api - # via mpcontribs-api (pyproject.toml) -mpmath==1.3.0 - # via sympy -narwhals==2.21.2 - # via plotly -nbclassic==1.3.3 - # via notebook -nbclient==0.10.4 - # via nbconvert -nbconvert==7.17.1 - # via - # jupyter-server - # notebook -nbformat==5.10.4 - # via - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # nbclient - # nbconvert - # notebook -nest-asyncio==1.6.0 - # via - # ipykernel - # jupyter-client - # nbclassic - # notebook -networkx==3.6.1 - # via pymatgen-core -notebook==6.5.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -notebook-shim==0.2.4 - # via nbclassic -numpy==2.4.5 - # via - # contourpy - # matplotlib - # monty - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pandas - # pymatgen-core - # scipy - # spglib -opentelemetry-api==1.41.1 - # via ddtrace -orjson==3.11.9 - # via - # flask-mongorest-mpcontribs - # pymatgen-core -packaging==26.2 - # via - # gunicorn - # ipykernel - # jupyter-events - # jupyter-server - # marshmallow - # matplotlib - # nbconvert - # plotly - # pytest -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.3 - # via pymatgen-core -pandocfilters==1.5.1 - # via nbconvert -parso==0.8.7 - # via jedi -pexpect==4.9.0 - # via ipython -pillow==12.2.0 - # via matplotlib -pint==0.25.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -platformdirs==4.9.6 - # via - # jupyter-core - # pint -plotly==6.7.0 - # via pymatgen-core -pluggy==1.6.0 - # via pytest -prometheus-client==0.25.0 - # via - # jupyter-server - # notebook -prompt-toolkit==3.0.52 - # via ipython -psutil==7.2.2 - # via - # ipykernel - # ipython -psycopg2-binary==2.9.12 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -ptyprocess==0.7.0 - # via - # pexpect - # terminado -pure-eval==0.2.3 - # via stack-data -pycodestyle==2.14.0 - # via - # flake8 - # pytest-pycodestyle -pycparser==3.0 - # via cffi -pyflakes==3.4.0 - # via flake8 -pygments==2.20.0 - # via - # ipython - # ipython-pygments-lexers - # nbconvert - # pytest -pymatgen==2026.5.4 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pymatgen-core==2026.5.17 - # via pymatgen -pymongo==4.17.0 - # via - # flask-mongorest-mpcontribs - # mongoengine -pyopenssl==26.2.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -pytest==9.0.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pytest-flake8 - # pytest-pycodestyle - # pytest-xdist -pytest-flake8==1.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytest-pycodestyle==2.5.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytest-xdist==3.8.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # dateparser - # flask-mongorest-mpcontribs - # freezegun - # jupyter-client - # matplotlib - # pandas - # rq-scheduler -python-json-logger==4.1.0 - # via jupyter-events -python-mimeparse==2.0.0 - # via mimerender-pr36 -python-snappy==0.7.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -pytz==2026.2 - # via dateparser -pyyaml==6.0.3 - # via - # flasgger-tschaume - # jupyter-events -pyzmq==27.1.0 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook -redis==7.4.0 - # via - # flask-rq2 - # flask-sse - # rq -referencing==0.37.0 - # via - # jsonschema - # jsonschema-specifications - # jupyter-events -regex==2026.5.9 - # via dateparser -requests==2.34.2 - # via - # atlasq-tschaume - # pymatgen-core -rfc3339-validator==0.1.4 - # via - # jsonschema - # jupyter-events -rfc3986-validator==0.1.1 - # via - # jsonschema - # jupyter-events -rfc3987-syntax==1.1.0 - # via jsonschema -rpds-py==0.30.0 - # via - # jsonschema - # referencing -rq==2.3.2 - # via - # flask-rq2 - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # rq-scheduler -rq-scheduler==0.14.0 - # via flask-rq2 -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.17.0 - # via boto3 -scipy==1.17.1 - # via pymatgen-core -send2trash==2.1.0 - # via - # jupyter-server - # notebook -setproctitle==1.3.7 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -six==1.17.0 - # via - # flasgger-tschaume - # flask-sse - # python-dateutil - # rfc3339-validator -soupsieve==2.8.3 - # via beautifulsoup4 -spglib==2.7.0 - # via pymatgen-core -stack-data==0.6.3 - # via ipython -supervisor==4.3.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -terminado==0.18.1 - # via - # jupyter-server - # jupyter-server-terminals - # notebook -tinycss2==1.4.0 - # via bleach -tornado==6.5.5 - # via - # ipykernel - # jupyter-client - # jupyter-server - # notebook - # terminado -tqdm==4.67.3 - # via pymatgen-core -traitlets==5.15.0 - # via - # ipykernel - # ipython - # jupyter-client - # jupyter-core - # jupyter-events - # jupyter-server - # matplotlib-inline - # nbclient - # nbconvert - # nbformat - # notebook -typing-extensions==4.15.0 - # via - # anyio - # beautifulsoup4 - # flexcache - # flexparser - # opentelemetry-api - # pint - # pyopenssl - # referencing - # spglib -tzdata==2026.2 - # via arrow -tzlocal==5.3.1 - # via dateparser -uncertainties==3.2.3 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) - # pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.7.0 - # via - # botocore - # requests -wcwidth==0.7.0 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -webencodings==0.5.1 - # via - # bleach - # tinycss2 -websocket-client==1.9.0 - # via - # jupyter-server - # mpcontribs-api - # mpcontribs-api (pyproject.toml) -werkzeug==3.1.8 - # via - # flasgger-tschaume - # flask -wrapt==2.1.2 - # via ddtrace -zipp==3.23.1 - # via importlib-metadata -zope-event==6.2 - # via gevent -zope-interface==8.4 - # via gevent -zstandard==0.25.0 - # via - # mpcontribs-api - # mpcontribs-api (pyproject.toml) diff --git a/mpcontribs-api/scripts/healthchecks.py b/mpcontribs-api/scripts/healthchecks.py index e0b339616..ff3009573 100644 --- a/mpcontribs-api/scripts/healthchecks.py +++ b/mpcontribs-api/scripts/healthchecks.py @@ -1,5 +1,6 @@ import os import sys + import requests for deployment in os.environ.get("DEPLOYMENTS", "ml:10002").split(","): diff --git a/mpcontribs-api/scripts/start.sh b/mpcontribs-api/scripts/start.sh index dc4022f9b..145274328 100755 --- a/mpcontribs-api/scripts/start.sh +++ b/mpcontribs-api/scripts/start.sh @@ -8,15 +8,6 @@ sleep $zzz PMGRC=$HOME/.pmgrc.yaml [[ ! -e "$PMGRC" ]] && echo "PMG_DUMMY_VAR: dummy" >"$PMGRC" -STATS_ARG="" -SERVER_APP="mpcontribs.api:create_app()" -WAIT_FOR="wait-for-it.sh $JUPYTER_GATEWAY_HOST -q -t 50" - set -x -if [[ -n "$DD_TRACE_HOST" ]]; then - wait-for-it.sh "$DD_TRACE_HOST" -q -s -t 10 && STATS_ARG="--statsd-host $DD_AGENT_HOST:8125" || echo "WARNING: datadog agent unreachable" -fi - -[[ -n "$STATS_ARG" ]] && CMD="ddtrace-run gunicorn $STATS_ARG" || CMD="gunicorn" -exec $WAIT_FOR -- $CMD $SERVER_APP +exec uvicorn mpcontribs_api.app:app --host 0.0.0.0 --port "$API_PORT" --workers "${NWORKERS:-2}" diff --git a/mpcontribs-api/scripts/start_rq.sh b/mpcontribs-api/scripts/start_rq.sh index 812ef8be4..821f6e0d0 100755 --- a/mpcontribs-api/scripts/start_rq.sh +++ b/mpcontribs-api/scripts/start_rq.sh @@ -1,16 +1,20 @@ #!/bin/bash +# +# RQ worker placeholder. +# +# The FastAPI rewrite has not yet ported the background worker (the old worker ran +# `flask rq worker`, and Flask is gone). This stub exists so supervisord's `*-worker` +# programs — referenced by supervisord.conf.jinja and started by main.py's `start("rq:*")` +# — have a command to run and do not crash-loop or enter FATAL. +# +# It honors the same startup stagger as the api process, then blocks so supervisord sees a +# healthy RUNNING process. Replace the `exec sleep infinity` below with the real worker +# entrypoint once background processing is reimplemented. set -e zzz=$((DEPLOYMENT * 60)) echo "$SUPERVISOR_PROCESS_NAME: waiting for $zzz seconds before start..." -sleep $zzz +sleep "$zzz" -CMD="flask rq $1" -set -x - -if [[ -n "$DD_TRACE_HOST" ]]; then - wait-for-it.sh "$DD_TRACE_HOST" -q -s -t 10 && CMD="ddtrace-run $CMD" || echo "WARNING: datadog agent unreachable" -fi - -exec wait-for-it.sh "$JUPYTER_GATEWAY_HOST" -q -s -t 50 -- \ - wait-for-it.sh "$MPCONTRIBS_API_HOST" -q -s -t 15 -- $CMD +echo "$SUPERVISOR_PROCESS_NAME: RQ worker not yet ported to the FastAPI rewrite; idling." +exec sleep infinity diff --git a/mpcontribs-api/mpcontribs/api/contributions/__init__.py b/mpcontribs-api/src/mpcontribs_api/__init__.py similarity index 100% rename from mpcontribs-api/mpcontribs/api/contributions/__init__.py rename to mpcontribs-api/src/mpcontribs_api/__init__.py diff --git a/mpcontribs-api/src/mpcontribs_api/_openapi.py b/mpcontribs-api/src/mpcontribs_api/_openapi.py new file mode 100644 index 000000000..fcd06c91c --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/_openapi.py @@ -0,0 +1,47 @@ +openapi_tags = [ + { + "name": "projects", + "description": "contain provenance information about contributed datasets. Deleting projects will also delete " + "all contributions including tables, structures, attachments, notebooks and cards for the project. Only users " + "who have been added to a project can update its contents. While unpublished, only users on the project can " + "retrieve its data or view it on the Portal. Making a project public does not automatically publish all its " + "contributions, tables, attachments, and structures. These are separately set to public individually or in " + "bulk.", + }, + { + "name": "contributions", + "description": "contain simple hierarchical data which will show up as cards on the MP details page for MP " + "material(s). Tables (rows and columns), structures, and attachments can be added to a contribution. " + "Each contribution uses `mp-id` or composition as identifier to associate its data with the according entries " + "on MP. Only admins or users on the project can create, update or delete contributions, and while unpublished, " + "retrieve its data or view it on the Portal. Contribution components (tables, structures, and attachments) are " + "deleted along with a contribution.", + }, + { + "name": "structures", + "description": "are [pymatgen structures](https://pymatgen.org/pymatgen.electronic_structure.html) which can " + " be added to a contribution.", + }, + { + "name": "tables", + "description": "are simple spreadsheet-type tables with columns and rows saved as " + "[Polars DataFrames](https://docs.pola.rs/api/python/stable/reference/dataframe/index.html) which can be added " + "to a contribution.", + }, + { + "name": "attachments", + "description": "are files saved as objects in AWS S3 and not accessible for querying (only retrieval) which " + "can be added to a contribution.", + }, +] + +contact_info = { + "name": "MPContribs", + "url": "https://mpcontribs.org/", + "email": "contribs@materialsproject.org", +} + +license_info = { + "name": "Creative Commons Attribution 4.0 International License", + "url": "https://creativecommons.org/licenses/by/4.0/", +} diff --git a/mpcontribs-api/mpcontribs/api/projects/__init__.py b/mpcontribs-api/src/mpcontribs_api/api/v1/__init__.py similarity index 100% rename from mpcontribs-api/mpcontribs/api/projects/__init__.py rename to mpcontribs-api/src/mpcontribs_api/api/v1/__init__.py diff --git a/mpcontribs-api/src/mpcontribs_api/api/v1/router.py b/mpcontribs-api/src/mpcontribs_api/api/v1/router.py new file mode 100644 index 000000000..871ce25bd --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/api/v1/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from mpcontribs_api.domains.attachments.router import router as attachments_router +from mpcontribs_api.domains.contributions.router import router as contributions_router +from mpcontribs_api.domains.limits.router import router as limits_router +from mpcontribs_api.domains.projects.router import router as projects_router +from mpcontribs_api.domains.structures.router import router as structures_router +from mpcontribs_api.domains.tables.router import router as tables_router + +router = APIRouter() + +router.include_router(attachments_router, prefix="/attachments", tags=["attachments"]) +router.include_router(contributions_router, prefix="/contributions", tags=["contributions"]) +router.include_router(limits_router, prefix="/limits", tags=["limits"]) +router.include_router(projects_router, prefix="/projects", tags=["projects"]) +router.include_router(structures_router, prefix="/structures", tags=["structures"]) +router.include_router(tables_router, prefix="/tables", tags=["tables"]) diff --git a/mpcontribs-api/src/mpcontribs_api/app.py b/mpcontribs-api/src/mpcontribs_api/app.py new file mode 100644 index 000000000..58b77a2a7 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/app.py @@ -0,0 +1,140 @@ +from collections.abc import AsyncGenerator +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager +from typing import cast + +import aioboto3 +from beanie import init_beanie +from botocore.config import Config +from fastapi import Depends, FastAPI +from pymongo import AsyncMongoClient +from types_aiobotocore_s3 import S3Client + +from mpcontribs_api._openapi import contact_info, license_info, openapi_tags +from mpcontribs_api.api.v1.router import router as v1_router +from mpcontribs_api.authz import api_key_scheme +from mpcontribs_api.config import Settings, get_settings +from mpcontribs_api.domains._redirects.router import router as redirects_router +from mpcontribs_api.domains.attachments.models import Attachment +from mpcontribs_api.domains.contributions.models import Contribution +from mpcontribs_api.domains.healthcheck.router import router as healthcheck_router +from mpcontribs_api.domains.projects.models import Project +from mpcontribs_api.domains.structures.models import Structure +from mpcontribs_api.domains.tables.models import Table +from mpcontribs_api.exceptions import register_exception_handlers +from mpcontribs_api.logging import configure_logging, get_logger +from mpcontribs_api.middleware import ( + BodySizeLimitMiddleware, + RequestContextMiddleware, + configure_tracing, + instrument_app, +) + +logger = get_logger(__name__) + + +async def _setup_mongo(app: FastAPI, settings: Settings, stack: AsyncExitStack) -> None: + """Setting up app-wide access to MongoDB via AsyncMongoClient and Beanie""" + client = AsyncMongoClient( + settings.mongo.uri.get_secret_value(), + appname=settings.mongo.app_name, + maxPoolSize=settings.mongo.max_pool_size, + minPoolSize=settings.mongo.min_pool_size, + maxIdleTimeMS=settings.mongo.max_idle_time_ms, + timeoutMS=settings.mongo.timeout_ms, + serverSelectionTimeoutMS=settings.mongo.server_selection_timeout_ms, + retryWrites=True, + retryReads=True, + compressors=settings.mongo.compressors, + readPreference=settings.mongo.read_preference, + uuidRepresentation="standard", + ) + # Fail fast if the DB is unreachable + await client.admin.command("ping") + logger.info("connected to mongo", extra={"db": settings.mongo.db_name}) + stack.push_async_callback(client.close) + + app.state.mongo_client = client + app.state.db = client[settings.mongo.db_name] + await init_beanie( + database=client[settings.mongo.db_name], + document_models=[ + Project, + Contribution, + Attachment, + Structure, + Table, + ], + ) + + +async def _setup_s3(app: FastAPI, settings: Settings, stack: AsyncExitStack) -> None: + """Setting up app-wide access to AWS S3 via aioboto3""" + session = aioboto3.Session() + cm = cast( + AbstractAsyncContextManager[S3Client], + session.client( + "s3", + region_name=settings.aws.region, + config=Config(max_pool_connections=settings.aws.max_pool_connections), + ), + ) + s3 = await stack.enter_async_context(cm) + app.state.boto_session = session + app.state.s3 = s3 + logger.info("connected to s3") + + +def _build_lifespan(settings: Settings): + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + async with AsyncExitStack() as stack: + await _setup_mongo(app, settings, stack) + await _setup_s3(app, settings, stack) + yield + # stack unwinds in reverse: s3 closed, then mongo + + return lifespan + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or get_settings() + # Register OTEL providers before logging so the logs pipeline attaches to live telemetry. + configure_tracing(settings) + configure_logging(settings) + + app = FastAPI( + title="mpcontribs-api", + description="Operations to contribute, update and retrieve materials data on Materials Project", + version=settings.version, + debug=settings.environment != "prod", + lifespan=_build_lifespan(settings), + terms_of_service="https://materialsproject.org/terms", + license_info=license_info, + contact=contact_info, + # openapi_url="/api/v1/openapi.json", + openapi_tags=openapi_tags, + swagger_ui_parameters={ + "docExpansion": "none", + }, + dependencies=[Depends(api_key_scheme)], + ) + + # Reject oversized request bodies before they're buffered into memory. Added before + # RequestContextMiddleware: Starlette inserts each added middleware at the front of the stack, + # so the later-added RequestContextMiddleware stays outermost and still access-logs rejections. + app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.mongo.max_request_bytes) + # Add request context to the logger + app.add_middleware(RequestContextMiddleware) + # Emit server-side request spans/metrics (no-op when telemetry is disabled). + instrument_app(app, settings) + register_exception_handlers(app) + app.include_router(healthcheck_router, prefix="/healthcheck") + app.include_router(v1_router, prefix="/api/v1") + # Legacy (root-path) endpoints: 308-redirect to /api/v1 where a counterpart + # exists, else 410 Gone. Registered last so it never shadows live routes. + app.include_router(redirects_router) + + return app + + +app = create_app() diff --git a/mpcontribs-api/src/mpcontribs_api/authz.py b/mpcontribs-api/src/mpcontribs_api/authz.py new file mode 100644 index 000000000..1982d1a12 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/authz.py @@ -0,0 +1,64 @@ +from typing import Any + +from fastapi.security import APIKeyHeader +from pydantic import BaseModel, ConfigDict, model_validator + +from mpcontribs_api.config import get_settings + +settings = get_settings() + + +api_key_scheme = APIKeyHeader( + name="X-API-KEY", + auto_error=False, + description="MP API key to authorize requests", +) + + +ADMIN_GROUP = settings.mongo.admin_group + + +class User(BaseModel): + """User definition derived from request headers. + + Attributes: + consumer_id (str | None): Kong id, for logging only + username (str | None): the username of the active user - if None, the user is anonymous + groups (frozenset[str]): the groups the user is part of - used for access control + """ + + model_config = ConfigDict(frozen=True) + consumer_id: str | None = None + username: str | None = None + groups: frozenset[str] = frozenset() + + @model_validator(mode="before") + @classmethod + def drop_admin_on_anonymous(cls, config: dict[str, Any]) -> dict[str, Any]: + if not config.get("username"): + groups = config.get("groups", frozenset()) + config["groups"] = frozenset(g for g in groups if g != ADMIN_GROUP) + return config + + @property + def is_anonymous(self) -> bool: + return self.username is None + + @property + def is_admin(self) -> bool: + return (not self.is_anonymous) and (ADMIN_GROUP in self.groups) + + def has_role(self, role: str) -> bool: + return role in self.groups + + @property + def writable_projects(self) -> frozenset[str]: + """Projects this user may write to. Admins are unbounded (handled by can_write)""" + if self.is_anonymous: + return frozenset() + # exclude the admin sentinel so it never leaks into a $in / membership test + return frozenset(g for g in self.groups if g != ADMIN_GROUP) + + def can_write(self, project: str) -> bool: + """Single source of truth for write authorization.""" + return self.is_admin or project in self.writable_projects diff --git a/mpcontribs-api/src/mpcontribs_api/config.py b/mpcontribs-api/src/mpcontribs_api/config.py new file mode 100644 index 000000000..3995eebb0 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/config.py @@ -0,0 +1,194 @@ +from functools import lru_cache +from typing import Literal + +from pydantic import BaseModel, Field, SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RedisSettings(BaseModel): + address: SecretStr + url: SecretStr + + +class ObservabilitySettings(BaseModel): + """OpenTelemetry settings. + + The application is vendor-neutral: it emits traces, metrics, and logs via OTLP/gRPC to a + collector (in our deployment, the Datadog Agent's OTLP receiver). Datadog is purely the backend. + """ + + enabled: bool = Field( + default=True, + description="Master switch for OTEL setup. Disable in tests/local runs without a collector.", + ) + service_name: str = Field( + default="contribs-apis", + description="Value of the service.name resource attribute. Kept as the legacy Datadog " + "service name so existing dashboards and monitors keep resolving.", + ) + otlp_endpoint: str = Field( + default="localhost:4317", + description="host:port of the OTLP/gRPC receiver (the Datadog Agent's OTLP endpoint).", + ) + insecure: bool = Field( + default=True, + description="Use an insecure (plaintext) gRPC channel. True for a local/sidecar agent without TLS.", + ) + metric_export_interval_ms: int = Field( + default=60_000, + description="How often (ms) the periodic metric reader exports to the collector.", + ) + + +class AwsSettings(BaseModel): + """AWS Settings + + Primarily used for S3 access + """ + + region: str = Field(default="us-east-1", description="The region to connect to") + max_pool_connections: int = Field( + default=10, + description="The maximum number of connections the app is allowed to have to S3", + ) + health_bucket: str = Field( + default="contributions", + description="The S3 bucket probed by the healthcheck to verify connectivity", + ) + + +class MongoSettings(BaseModel): + """MongoDB settings. + + Provided defaults are the defaults of AsyncMongoClient + """ + + # Required + uri: SecretStr = Field(description="The full uri from MongoDB (username and password included)") + db_name: str + + # Optional + app_name: str = Field( + default="MPContribs_FastAPI_Server", + description="The name of the application that created this AsyncMongoClient instance. The server will log this " + "value upon establishing each connection. It is also recorded in the slow query log and profile collections.", + ) + max_pool_size: int = Field( + default=100, + description="Maximum number of allowed concurrent connection to each server. Can be '0' or 'None', both of " + "which allow any number of connections", + ) + min_pool_size: int = Field( + default=0, + description="Minimum number of concurent connections that the pool will maintain connected to each server ", + ) + datetime_conversion: Literal["datetime_ms", "datetime", "datetime_auto", "datetime_clamp"] = Field( + default="datetime", + description="Specifies how UTC datetimes should be decoded within BSON", + ) + server_selection_timeout_ms: int = Field( + default=30_000, + description="Controls how long (in milliseconds) the driver will wait to find an available, appropriate server " + "to carry out a database operation;" + "while it is waiting, multiple server monitoring operations may be carried out", + ) + + admin_group: str = Field( + default="admin", + description="Name of admin group to consider in requests to MongoDB. Not directly passed to Mongo, but " + "consumed by auth.", + ) + + compressors: str = Field( + default="snappy,zstd,zlib", + description="Comma separated list of compressors for wire protocol compression. Compression support must also " + "be enabled on the server", + ) + + read_preference: str = Field( + default="primary", + description="The replica set read preference for this client. One of primary, primaryPreferred, secondary, " + "secondaryPreferred, or nearest", + ) + + # TODO: Tune default + max_concurrent_transactions: int = Field( + default=16, + description="Upper bound on per-contribution transactions running in parallel during a bulk insert. Clamped at " + "construction to max_pool_size // 2 so reads on the same request can still acquire connections.", + ) + # TODO: Tune default + max_components_per_contribution: int = Field( + default=500, + description="Hard ceiling on structures + tables + attachments for a single contribution. Anything larger is " + "rejected upfront so we don't burn a transaction slot on a request guaranteed to exceed " + "transactionLifetimeLimitSeconds (default 60s).", + ) + # TODO: Tune default + component_insert_chunk_size: int = Field( + default=100, + description="Batch size used by component repositories when chunking insert_many calls inside a transaction.", + ) + # TODO: Tune default + max_request_bytes: int = Field( + default=16 * 1024 * 1024, + description="Hard ceiling on the size (bytes) of any single request body. Requests exceeding it are rejected " + "with 413 before the body is read into memory, so one caller can't OOM the worker. Mirrors the client's " + "MAX_PAYLOAD (15MB) with headroom.", + ) + # TODO: Tune default + bulk_write_limit: int = Field( + default=1000, + description="Maximum number of items accepted in a single bulk contribution POST/PUT. Larger batches are " + "rejected with 422; callers should chunk (or use the async ingestion endpoint). Advertised via GET /limits.", + ) + max_idle_time_ms: int = Field( + default=30_000, + description="The maximum allowed time a single connection is allowed to sit idle", + ) + timeout_ms: int = Field(default=60_000, description="The end-to-end allowed time for an operation") + + @model_validator(mode="after") + def _clamp_concurrency(self): + if self.max_pool_size: + per_request_cap = max(1, self.max_pool_size // 2) + if self.max_concurrent_transactions > per_request_cap: + self.max_concurrent_transactions = per_request_cap + return self + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_nested_delimiter="__", + env_prefix="MPCONTRIBS_", + ) + + environment: Literal["dev", "prod"] + + # MPContribs_mongo__* + # requires uri and db_name + mongo: MongoSettings + + # MPContribs_aws__* + aws: AwsSettings = Field(default_factory=AwsSettings) + + # MPContribs_redis__* + redis: RedisSettings + + # MPContribs_otel__* + otel: ObservabilitySettings = Field(default_factory=ObservabilitySettings) + + # SMTP Settings + mail_default_sender: str = Field( + description="SMTP Server to send out notifications on new projects and other important moments" + ) + + # General/Informative settings + version: str + + +@lru_cache +def get_settings() -> Settings: + # Fields are populated from env vars at runtime, not from arguments - pyright can't see that + return Settings() # pyright: ignore[reportCallIssue] diff --git a/mpcontribs-api/src/mpcontribs_api/dependencies.py b/mpcontribs-api/src/mpcontribs_api/dependencies.py new file mode 100644 index 000000000..cadf3d001 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/dependencies.py @@ -0,0 +1,85 @@ +from typing import Annotated + +import aioboto3 +import structlog +from fastapi import Depends, Request +from pymongo import AsyncMongoClient +from pymongo.asynchronous.database import AsyncDatabase +from types_aiobotocore_s3 import S3Client + +from mpcontribs_api.authz import User +from mpcontribs_api.exceptions import AuthenticationError + + +def get_db(request: Request) -> AsyncDatabase: + return request.app.state.db + + +DbDep = Annotated[AsyncDatabase, Depends(get_db)] + + +def get_boto(request: Request) -> aioboto3.Session: + return request.app.state.boto_session + + +BotoDep = Annotated[aioboto3.Session, Depends(get_boto)] + + +def get_s3(request: Request) -> S3Client: + return request.app.state.s3 + + +S3Dep = Annotated[S3Client, Depends(get_s3)] + + +def get_mongo_client(request: Request) -> AsyncMongoClient: + return request.app.state.mongo_client + + +MongoClientDep = Annotated[AsyncMongoClient, Depends(get_mongo_client)] + + +def _split(raw: str | None) -> set[str]: + return {g.strip() for g in (raw or "").split(",") if g.strip()} + + +def get_user(request: Request) -> User: + """Dissects request headers for user-related keys.""" + h = request.headers + explicit_anon = h.get("x-anonymous-consumer", "").lower() == "true" + username = h.get("x-consumer-username") or None + if explicit_anon or username is None: + user = User() # anonymous = all defaults + else: + groups = _split(h.get("x-authenticated-groups")) | _split(h.get("x-consumer-groups")) + user = User( + consumer_id=h.get("x-consumer-id"), + username=username, + groups=frozenset(groups), + ) + structlog.contextvars.bind_contextvars( + consumer_id=user.consumer_id, + is_admin=user.is_admin, + ) + return user + + +UserDep = Annotated[User, Depends(get_user)] + + +def require_user(user: UserDep) -> User: + if user.is_anonymous: + raise AuthenticationError("authentication required") + return user + + +# AuthedDep = Annotated[User, Depends(require_user)] + + +# def require_role(role: str): +# def checker(user: AuthedDep) -> User: +# if not user.has_role(role): +# raise PermissionError(required_role=role) +# return user + +# return Annotated[User, Depends(checker)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_redirects/router.py b/mpcontribs-api/src/mpcontribs_api/domains/_redirects/router.py new file mode 100644 index 000000000..d965e4eec --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_redirects/router.py @@ -0,0 +1,161 @@ +"""Compatibility shims for the legacy (Flask/flask-mongorest) MPContribs API. + +The old API was served from the root path (e.g. ``/contributions/``, +``/projects//``). The rewrite serves everything under ``/api/v1`` with +slightly different paths and verbs. This router is mounted at the root and: + +- 308-redirects every legacy endpoint that has a direct counterpart to its new + location (preserving method, body, and query string), and +- returns ``410 Gone`` with a machine-readable "deprecated" body for legacy + endpoints that have no equivalent in the new API (notebooks, the formula/term + search helpers, project-application approval links, and project creation). + +Redirects are permanent (308) so well-behaved clients update their bookmarks +while keeping the original HTTP method and request body intact. +""" + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, RedirectResponse +from starlette.status import HTTP_308_PERMANENT_REDIRECT, HTTP_410_GONE + +router = APIRouter(include_in_schema=False) + +# Base path of the new API. The legacy API lived at the service root. +API_V1 = "/api/v1" + + +def _redirect(request: Request, new_path: str) -> RedirectResponse: + """308-redirect to ``new_path`` under the v1 API, preserving the query string. + + 308 (rather than 301/302) keeps the original method and body, so a legacy + ``POST``/``PUT``/``DELETE`` is replayed against the new endpoint instead of + being silently downgraded to a ``GET``. + """ + target = f"{API_V1}{new_path}" + if request.url.query: + target = f"{target}?{request.url.query}" + return RedirectResponse(url=target, status_code=HTTP_308_PERMANENT_REDIRECT) + + +def _deprecated(message: str, *, replacement: str | None = None) -> JSONResponse: + """Return a ``410 Gone`` in the app's uniform error shape. + + Used for legacy endpoints that have no counterpart in the new API. + """ + detail: dict[str, str] = {} + if replacement is not None: + detail["replacement"] = replacement + body: dict = {"error": {"code": "endpoint_deprecated", "message": message}} + if detail: + body["error"]["detail"] = detail + return JSONResponse( + status_code=HTTP_410_GONE, + content=body, + headers={"Deprecation": "true"}, + ) + + +# --------------------------------------------------------------------------- +# contributions +# --------------------------------------------------------------------------- +@router.get("/contributions/search") +def redirect_contributions_search() -> JSONResponse: + # Formula autocomplete (Atlas $search) was not ported to the new API. + return _deprecated("The contributions formula search endpoint has been removed.") + + +@router.get("/contributions/download/{short_mime}/") +def redirect_download_contributions(request: Request, short_mime: str) -> RedirectResponse: + return _redirect(request, f"/contributions/download/{short_mime}") + + +@router.api_route("/contributions/", methods=["GET", "POST", "PUT", "DELETE"]) +def redirect_contributions_collection(request: Request) -> RedirectResponse: + # GET=list, POST=bulk insert, PUT=bulk upsert, DELETE=delete-by-filter. + return _redirect(request, "/contributions") + + +@router.api_route("/contributions/{pk}/", methods=["GET", "PUT", "DELETE"]) +def redirect_contribution_item(request: Request, pk: str) -> RedirectResponse: + # GET=fetch, PUT=update/upsert, DELETE=delete (all keyed by id). + return _redirect(request, f"/contributions/{pk}") + + +# --------------------------------------------------------------------------- +# projects +# --------------------------------------------------------------------------- +@router.get("/projects/search") +def redirect_projects_search() -> JSONResponse: + return _deprecated("The projects search endpoint has been removed.") + + +@router.get("/projects/applications/{token}") +@router.get("/projects/applications/{token}/{action}") +def redirect_projects_applications(token: str, action: str | None = None) -> JSONResponse: + # Email-driven project approval/denial links; not part of the new API. + return _deprecated("Project application approval links have been removed.") + + +@router.api_route("/projects/", methods=["GET", "POST"], response_model=None) +def redirect_projects_collection(request: Request) -> RedirectResponse | JSONResponse: + if request.method == "POST": + # No project-creation endpoint exists in the new API. + return _deprecated( + "Creating projects via POST is no longer supported. Create a project with PUT /api/v1/projects/{id}.", + replacement=f"{API_V1}/projects/{{id}}", + ) + return _redirect(request, "/projects") + + +@router.api_route("/projects/{pk}/", methods=["GET", "PUT", "DELETE"]) +def redirect_project_item(request: Request, pk: str) -> RedirectResponse: + # GET=fetch, PUT=update/upsert, DELETE=delete. + return _redirect(request, f"/projects/{pk}") + + +# --------------------------------------------------------------------------- +# Components +# --------------------------------------------------------------------------- +def _register_component_redirects(component: str) -> None: + @router.get(f"/{component}/download/{{short_mime}}/", name=f"redirect_download_{component}") + def redirect_download(request: Request, short_mime: str) -> RedirectResponse: + return _redirect(request, f"/{component}/download/{short_mime}") + + @router.get(f"/{component}/", name=f"redirect_{component}_collection") + def redirect_collection(request: Request) -> RedirectResponse: + return _redirect(request, f"/{component}") + + @router.get(f"/{component}/{{pk}}/", name=f"redirect_{component}_item") + def redirect_item(request: Request, pk: str) -> RedirectResponse: + return _redirect(request, f"/{component}/{pk}") + + +for _component in ("structures", "tables", "attachments"): + _register_component_redirects(_component) + + +# --------------------------------------------------------------------------- +# notebooks +# --------------------------------------------------------------------------- +_NOTEBOOKS_GONE = "The notebooks API has been removed." + + +@router.get("/notebooks/build") +def redirect_notebooks_build() -> JSONResponse: + return _deprecated(_NOTEBOOKS_GONE) + + +@router.get("/notebooks/result") +@router.get("/notebooks/result/{job_id}") +def redirect_notebooks_result(job_id: str | None = None) -> JSONResponse: + return _deprecated(_NOTEBOOKS_GONE) + + +@router.get("/notebooks/") +def redirect_notebooks_collection() -> JSONResponse: + return _deprecated(_NOTEBOOKS_GONE) + + +@router.get("/notebooks/{pk}/") +def redirect_notebooks_item(pk: str) -> JSONResponse: + return _deprecated(_NOTEBOOKS_GONE) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/bulk.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/bulk.py new file mode 100644 index 000000000..718e2c580 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/bulk.py @@ -0,0 +1,44 @@ +from typing import Any + +from pydantic import BaseModel + +from mpcontribs_api.exceptions import AppError + + +class BulkFailure(BaseModel): + """A single failed item in a bulk write, identified by its position in the input batch.""" + + index: int + identifier: dict[str, Any] | None = None + error_code: str + message: str + + +class BulkWriteSummary[T](BaseModel): + """Result of a bulk write that supports per-item failure reporting. + + ``total`` is the size of the input batch (succeeded + failed). ``succeeded`` carries the + fully inserted documents; ``failed`` carries one ``BulkFailure`` per rejected item, with + enough context for the caller to retry just those items. + """ + + total: int + succeeded: list[T] + failed: list[BulkFailure] + + +class BulkDeleteSummary[T](BaseModel): + num_deleted: int + num_children_deleted: int + + +def bulk_failure_from_exception(index: int, identifier: dict[str, Any] | None, exc: BaseException) -> BulkFailure: + """Translate any exception into a BulkFailure entry. + + ``AppError`` subclasses contribute their ``error_code`` and ``message``; everything else + collapses to ``internal_error`` with the exception class name in the message so we don't + leak tracebacks or framework internals to the client. + """ + if isinstance(exc, AppError): + return BulkFailure(index=index, identifier=identifier, error_code=exc.error_code, message=exc.message) + return BulkFailure(index=index, identifier=identifier, error_code="internal_error", message=type(exc).__name__) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py new file mode 100644 index 000000000..1af1edb05 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py @@ -0,0 +1,151 @@ +from typing import Any + +from beanie.operators import In +from fastapi_filter.contrib.beanie import Filter +from pydantic import BaseModel +from pymongo.asynchronous.client_session import AsyncClientSession + +from mpcontribs_api.authz import User +from mpcontribs_api.config import get_settings +from mpcontribs_api.domains._shared.models import Component, ComponentIn, DeleteResponse, DocumentOut +from mpcontribs_api.domains._shared.repository import MongoDbRepository +from mpcontribs_api.domains._shared.types import MD5Hash +from mpcontribs_api.exceptions import NotFoundError + + +class MongoDbComponentsRepository[ + TDoc: Component, + TIn: ComponentIn, + TOut: DocumentOut, + TFilter: Filter, + TPatch: BaseModel, +](MongoDbRepository[TDoc, TIn, TOut, TFilter, TPatch]): + @staticmethod + def _build_scope(user: User) -> dict[str, Any]: + return {} + + async def _existing_by_md5( + self, + md5s: list[MD5Hash], + session: AsyncClientSession | None = None, + ) -> dict[str, TDoc]: + # Full fetch so existing docs come back with their ids + # TODO: Most likely does a COLLSCAN - see if we can project to get a COVERED QUERY + existing_docs = await self.document_model.find( + In(self.document_model.md5, md5s), + session=session, + ).to_list() + return {doc.md5: doc for doc in existing_docs} + + async def insert_components( + self, + components: list[TIn], + session: AsyncClientSession | None = None, + ) -> list[TDoc]: + """Bulk-insert components, deduplicated by server-computed content hash. + + Each input is built into a full document via ``Component.from_input``, which assigns a fresh + id and computes ``md5`` from the content (the client never supplies it). Inputs are + deduplicated by md5 — both against documents already stored and against each other — so the + return list has one entry per *unique* content, in first-seen order. + + Args: + components (list[TIn]): components to insert + session (AsyncClientSession): optional client session; pass when inserting inside a transaction + """ + # Build full docs up front so md5 is server-computed before any dedup decision. + docs = [self.document_model.from_input(comp) for comp in components] + existing_by_md5 = await self._existing_by_md5([doc.md5 for doc in docs], session=session) + + # First-seen unique md5 order, and the new documents that need inserting. + unique_md5s: list[str] = [] + new_by_md5: dict[str, TDoc] = {} + for doc in docs: + if doc.md5 not in existing_by_md5 and doc.md5 not in new_by_md5: + new_by_md5[doc.md5] = doc + if doc.md5 not in unique_md5s: + unique_md5s.append(doc.md5) + + # Insert by chunks to stay within a transaction's payload budget. + new_docs = list(new_by_md5.values()) + chunk_size = get_settings().mongo.component_insert_chunk_size + for start in range(0, len(new_docs), chunk_size): + await self.document_model.insert_many( + new_docs[start : start + chunk_size], + ordered=False, + session=session, + ) + + # One resolved document per unique md5, in first-seen order. + resolved = existing_by_md5 | new_by_md5 + return [resolved[md5] for md5 in unique_md5s] + + async def insert_component(self, component: TIn, *, session: AsyncClientSession | None = None) -> TDoc: + """Insert a single component. + + Args: + component (TIn): the table to insert + + Returns: + TDoc: the component actually in the database + + Raises: + AppError: If insert_one returns None, raises + """ + return (await self.insert_components(components=[component], session=session))[0] + + async def get_component_by_id(self, id: str, fields: frozenset[str] | None) -> TDoc | TOut | None: + """Find a single component by id. See ``get_by_id``.""" + return await self.get_by_id(self._convert_object_id(id), fields) + + async def delete_components( + self, + filter: TFilter, + session: AsyncClientSession | None = None, + ) -> DeleteResponse: + """Deletes all components matching ``filter``. + + Args: + filter (TFilter): the query to filter components by + session (AsyncClientSession | None): the current session, used to guarantee transactions + + Returns: + DeleteResponse: A report of the deletion + """ + query = filter.filter(self.document_model.find(self._scope, session=session)) + result = await query.delete(session=session) + return DeleteResponse(num_deleted=result.deleted_count if result else 0) + + async def delete_component_by_id( + self, + id: str, + session: AsyncClientSession | None = None, + ) -> DeleteResponse: + """Deletes a single component by Id. + + Args: + id (str): the str representation of the component's ObjectId + session (AsyncClientSession | None): the current session, used to guarantee transactions + + Returns: + DeleteResponse: A report of the deletion + """ + return await self.delete_by_id(id=self._convert_object_id(id), session=session) + + async def patch_component_by_id(self, id: str, update: TPatch) -> TDoc: + """Partially update a component by id, recomputing its content hash. + + Components are content-addressed, so a content change must update ``md5``. Unlike the base + ``patch`` (an in-place ``$set``), this loads the full document, applies the set fields, + recomputes ``md5`` from ``hash_fields``, and saves — keeping md5 consistent with content. + """ + oid = self._convert_object_id(id) + doc = await self.document_model.find_one(self._scope, self.document_model.id == oid) + if doc is None: + raise NotFoundError(self._not_found(id)) + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(doc, field, value) + doc.md5 = doc.compute_md5() + await doc.save() + return doc diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py new file mode 100644 index 000000000..027d25dfb --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py @@ -0,0 +1,23 @@ +from collections.abc import Mapping +from typing import Any + +from fastapi_filter.contrib.beanie import Filter + + +class BaseFilter(Filter): + """Base filter that bridges Beanie's ``_id`` alias and fastapi-filter's raw field names. + + Beanie stores a document's primary key under Mongo's ``_id`` (``id`` is just a Pydantic alias), + but fastapi-filter builds query keys from the raw field name read off ``model_dump`` — without + aliases. An ``id`` filter would therefore query a non-existent ``{"id": ...}`` key and match + nothing, while a direct ``Document.id == x`` lookup (which Beanie resolves to ``_id``) succeeds. + Remapping the ``id`` key to ``_id`` here keeps the two read paths consistent. + + Domain filters should subclass this instead of fastapi-filter's ``Filter`` directly. + """ + + def _get_filter_conditions(self, nesting_depth: int = 1) -> list[tuple[Mapping[str, Any], Mapping[str, Any]]]: + return [ + ({"_id" if key == "id" else key: value for key, value in condition.items()}, options) + for condition, options in super()._get_filter_conditions(nesting_depth) + ] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/models.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/models.py new file mode 100644 index 000000000..2dfb073c6 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/models.py @@ -0,0 +1,126 @@ +import hashlib +import json +import unicodedata +from collections.abc import Mapping +from typing import Annotated, Any, ClassVar, Self + +from beanie import Document, PydanticObjectId +from pydantic import BaseModel, Field, model_validator +from pymongo.results import DeleteResult + +from mpcontribs_api import pagination +from mpcontribs_api.domains._shared.types import MD5Hash +from mpcontribs_api.projection import SparseFieldsModel + + +class BaseDocumentWithInput[TId](Document): + """A stored resource document with a required ``id`` and an input counterpart. + + Subclasses bind their id type as ``TId``. The ``id`` is declared here as required and non-null so + the repository can always read and key on it, while ``TId`` lets each resource pick its own id + type (``ShortStr`` for projects, ``PydanticObjectId`` for contributions). ``from_input_model`` + translates a validated input payload into a full document; the base param is intentionally ``Any`` + so each resource's override can declare its concrete input model without violating LSP (input + models subclass their document, so they can't be bound as a class type parameter). + """ + + # Required, non-null, resource-specific id. Overrides Document's optional ``PydanticObjectId`` id. + id: TId = Field(alias="_id") # pyright: ignore[reportGeneralTypeIssues, reportIncompatibleVariableOverride] + + @classmethod + def from_input_model(cls, data: Any) -> Self: + """Translate a validated input payload into a full stored document.""" + return cls(**data.model_dump()) + + @staticmethod + def decode_cursor(cursor: str) -> str | PydanticObjectId: + """Decodes the cursor the an ObjectId""" + return PydanticObjectId(pagination.decode_cursor(cursor=cursor)) + + +class DocumentOut[TId](SparseFieldsModel): + """Base output model for resources addressed by an ``_id``. + + Mirrors :class:`BaseDocumentWithInput`: subclasses bind their id type as ``TId`` so each resource + owns its id type, while the field (optional, since projections may omit it) and its alias wiring + are declared once here for the repository to read off any resource's output model. + """ + + id: Annotated[TId | None, Field(alias="_id", serialization_alias="id")] = None + + +class DeleteResponse(BaseModel): + num_deleted: int + + @classmethod + def from_delete_result(cls, delete_result: DeleteResult) -> Self: + return cls(num_deleted=delete_result.deleted_count) + + +class ComponentDeleteResponse(DeleteResponse): + """Result of a component delete that may leave referenced components in place. + + ``num_deleted`` (inherited) counts components actually removed; ``referenced_ids`` are the + component ids skipped because a contribution still references them, and ``num_skipped`` is + their count. + """ + + referenced_ids: list[PydanticObjectId] = Field(default_factory=list) + num_skipped: int = 0 + + +def canonical_md5(payload: Mapping[str, Any]) -> str: + """MD5 hex digest of a content mapping, stable across processes/hosts.""" + text = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + normalized = unicodedata.normalize("NFC", text) + return hashlib.md5(normalized.encode("utf-8")).hexdigest() + + +class ComponentIn(BaseModel): + """Base for component input payloads. + + Components are content-addressed: the server computes ``md5`` from the content and assigns the + ``_id`` on insert, so neither is part of the input contract. Subclasses add the required content + fields for their resource. + """ + + name: str + + +class Component(BaseDocumentWithInput[PydanticObjectId]): + """Stored component document. + + ``md5`` is server-authoritative: it is (re)computed from ``hash_fields`` whenever a full document + is validated — on insert, on update, and on full-document reads — so a client-supplied value can + never define a component's content identity. + """ + + name: str + # Server-computed; the placeholder default is overwritten by ``_recompute_md5`` on validation. + md5: MD5Hash = Field(default="0" * 32) + + hash_fields: ClassVar[frozenset[str]] + + # The md5 functions look redundant but aren't, we should keep both + # Used in patching to compute the hash after an update - should not return self + def compute_md5(self) -> str: + payload = self.model_dump(mode="json", include=set(self.hash_fields), by_alias=False) + return canonical_md5(payload) + + # Used on validation - must return self + @model_validator(mode="after") + def _recompute_md5(self) -> Self: + self.md5 = self.compute_md5() + return self + + @classmethod + def from_input(cls, input: ComponentIn) -> Self: + """Build a stored document from an input payload, assigning a fresh id (md5 is computed). + + The default maps every input field onto the document one-to-one. Subclasses whose document + carries fields that are absent from the input or derived from it should override this - see + ``Table.from_input``, which computes ``total_data_rows`` from the data frame. + """ + payload = input.model_dump() + payload["_id"] = PydanticObjectId() + return cls.model_validate(payload) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py new file mode 100644 index 000000000..4e3277544 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py @@ -0,0 +1,317 @@ +import csv +import hashlib +import io +import json +import zlib +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable +from contextlib import AbstractAsyncContextManager +from typing import Any + +from beanie import PydanticObjectId, UpdateResponse +from beanie.operators import In, Set +from bson.errors import InvalidId +from fastapi_filter.contrib.beanie import Filter +from pydantic import BaseModel +from pymongo.asynchronous.client_session import AsyncClientSession +from types_aiobotocore_s3 import S3Client + +from mpcontribs_api.authz import User +from mpcontribs_api.domains._shared.models import BaseDocumentWithInput, DeleteResponse, DocumentOut +from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat +from mpcontribs_api.exceptions import ConflictError, NotFoundError, ValidationError +from mpcontribs_api.pagination import CursorParams, Page, encode_cursor + + +class MongoDbRepository[ + TDoc: BaseDocumentWithInput, + TIn: BaseModel, + TOut: DocumentOut, + TFilter: Filter, + TPatch: BaseModel, +](ABC): + """Base repository encapsulating shared MongoDB access patterns. + + Subclasses bind the document, input, output, filter, and patch types as type parameters, set + the matching ``document_model`` / ``out_model`` class attributes, and implement ``_build_scope`` + to enforce per-user authorization. Shared CRUD logic (scoping, projection, cursor pagination, + insertion, single-document read/patch/delete) lives here so it exists in exactly one place and + cannot drift between resources. Subclasses expose domain-named methods that either forward to a + base method (vocabulary + concrete types for routers, no logic) or implement a genuinely + different shape (bulk insert, compound-key upsert, download). + + Attributes: + document_model: the ``BaseDocumentWithInput`` subclass this repository operates on + out_model: the ``SparseFieldsModel`` subclass used to build projections for reads + _scope (dict[str, Any]): terms injected into every query to enforce user authorization + """ + + document_model: type[TDoc] + out_model: type[TOut] + + def __init__(self, user: User) -> None: + """Initializes an instance based on the current user. + + Args: + user (User): the current user requesting resources + """ + self._scope = self._build_scope(user) + + @staticmethod + @abstractmethod + def _build_scope(user: User) -> dict[str, Any]: + """Provides scope based on current user's permitted groups and publicly released data.""" + ... + + def _convert_object_id(self, id: str) -> PydanticObjectId: + """Converts the string representation of an ObjectId to an ObjectId""" + try: + return PydanticObjectId(id) + except InvalidId: + raise ValidationError("Incorrect Id format. Must be MongoDB ObjectId format.", id=id) from None + + def _not_found(self, id: str) -> str: + """Build a not-found message naming this repository's resource.""" + return f"{self.document_model.__name__} with id {id} not found" + + async def get_many( + self, + filter: TFilter, + fields: frozenset[str] | None = None, + pagination: CursorParams | None = None, + restrict_ids: Iterable[Any] | None = None, + ) -> Page[TOut]: + """Return a scoped, filtered, cursor-paginated page of projected documents. + + Args: + pagination (CursorParams): forward-only cursor parameters + filter (TFilter): the fastapi-filter query to apply on top of the user scope + fields (frozenset[str] | None): fields to project; if None the full document is returned + restrict_ids (Iterable | None): when provided, results are limited to these ids in + addition to the user scope. An empty iterable yields an empty page. Used to gate + reads that are authorized indirectly (e.g. components reachable via a contribution). + """ + pagination = pagination or CursorParams() + + projection = self.out_model.projection(fields) + query = filter.filter(self.document_model.find(self._scope)) + if restrict_ids is not None: + query = query.find(In(self.document_model.id, list(restrict_ids))) + if pagination.cursor is not None: + query = query.find(self.document_model.id > self.document_model.decode_cursor(cursor=pagination.cursor)) # pyright: ignore[reportOptionalOperand] + docs = await query.sort(self.document_model.id).limit(pagination.limit + 1).project(projection).to_list() # pyright: ignore[reportArgumentType] + has_more = len(docs) > pagination.limit + items = docs[: pagination.limit] + next_cursor = encode_cursor(str(items[-1].id)) if has_more and items else None + return Page(items=items, next_cursor=next_cursor) + + async def get_by_id(self, id: Any, fields: frozenset[str] | None = None) -> TDoc | TOut | None: + """Return a single scoped document by id, projected to the requested fields. + + Args: + id (str): the id of the document to find + fields (frozenset[str] | None): fields to project; if None the full document is returned + """ + return await self.document_model.find_one( + self._scope, + self.document_model.id == id, + projection_model=self.out_model.projection(fields), + ) + + async def list_ids(self, filter: TFilter, session: AsyncClientSession | None = None) -> list[Any]: + """Return just the ids of scoped documents matching ``filter``. + + Projects to ``{"_id": 1}`` so the lookup can be served as a covered query from the + default ``_id`` index without materializing full documents. + + Args: + filter (TFilter): the fastapi-filter query to apply on top of the user scope + session (AsyncClientSession | None): optional client session for transactions + """ + projection = self.out_model.projection(frozenset({"id"})) + query = filter.filter(self.document_model.find(self._scope, session=session)) + docs = await query.project(projection).to_list() + return [doc.id for doc in docs] + + async def insert_one(self, in_resource: TIn) -> TDoc: + """Insert a new document built from its input model, rejecting duplicate ids. + + Args: + in_resource (TIn): the validated input payload to translate and store + """ + document = self.document_model.from_input_model(in_resource) + existing = await self.document_model.find_one(self.document_model.id == document.id) + if existing: + raise ConflictError(f"Cannot insert document.\n Document with ID {document.id} exists") + await document.insert() + return document + + async def delete_by_id(self, id: Any, session: AsyncClientSession | None = None) -> DeleteResponse: + """Delete a single scoped document by id. + + Scoping ensures callers cannot delete documents they are not permitted to see. + + Args: + id (str): the id of the document to delete + """ + doc = await self.document_model.find_one(self._scope, self.document_model.id == id, session=session) + if not doc: + raise NotFoundError("Document with id not found", id=id) + await doc.delete(session=session) + return DeleteResponse(num_deleted=1) + + async def delete_by_ids(self, ids: list[Any], session: AsyncClientSession | None = None) -> DeleteResponse: + """Delete multiple scoped documents by id. + + The user scope is injected so callers cannot delete documents they are not permitted to + see; out-of-scope ids simply match nothing and are reported as zero deletions. + + Args: + ids (list[Any]): list of ids to delete + session: the session to perform the deletes within + + Returns: + DeleteResponse: the result of the deletion + """ + docs = self.document_model.find(self._scope, In(self.document_model.id, ids), session=session) + delete_result = await docs.delete_many(session=session) + if not delete_result: + raise ValidationError("DeleteResult not returned internally") + return DeleteResponse.from_delete_result(delete_result) + + async def patch(self, id: Any, update: TPatch) -> TDoc: + """Partially update a single scoped document by id. + + Only fields explicitly set on ``update`` are applied. An empty patch is a no-op that still + returns the existing document for consistent behavior. Scoping ensures callers cannot patch + documents they are not permitted to see. + + Args: + id (str): the id of the document to update + update (TPatch): the partial update to apply; unset fields are dropped + """ + # Only retain set fields (patch) + update_data = update.model_dump(exclude_unset=True) + # If update is empty, return the model anyways (consistent behavior) + if not update_data: + existing = await self.document_model.find_one(self._scope, self.document_model.id == id) + if existing is None: + raise NotFoundError(self._not_found(id)) + return existing + + # Otherwise, update the fields fully (set) + # Brendan TODO: Set will replace an entire field + # - if we want to append to a list (ie. add a reference) we ned Push/AddToSet + query = self.document_model.find_one(self._scope, self.document_model.id == id).update( + Set(update_data), + response_type=UpdateResponse.NEW_DOCUMENT, + ) + updated = await query # pyright: ignore[reportGeneralTypeIssues] # beanie UpdateQuery is awaitable, but pyright doesn't see it + if updated is None: + raise NotFoundError(self._not_found(id)) + return updated + + def _hash_payload(self, payload: dict[str, Any], *, separators: tuple[str, str] = (",", ":")) -> str: + canonical = json.dumps( + payload, + sort_keys=True, + separators=separators, + ensure_ascii=True, + default=str, # filters may carry ObjectId/datetime values; stringify for a stable key + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def _get_serializer( + self, format: DownloadFormat, fields: frozenset[str] | None + ) -> Callable[[AsyncIterable[TOut]], AsyncIterable[bytes]]: + match format: + case DownloadFormat.JSONL: + return self._serialize_jsonl + case DownloadFormat.CSV: + return lambda rows: self._serialize_csv(rows, fields) + + @staticmethod + async def _serialize_jsonl(rows: AsyncIterable) -> AsyncIterator[bytes]: + async for out in rows: + yield out.model_dump_json().encode() + b"\n" + + @staticmethod + def _csv_cell(value: Any) -> Any: + """Render a cell value for CSV: scalars as-is, dict/list as JSON (not Python repr).""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + @staticmethod + async def _serialize_csv(rows: AsyncIterable, fields: frozenset[str] | None) -> AsyncIterator[bytes]: + buf = io.StringIO() + writer: csv.DictWriter | None = None + async for out in rows: + row = out.model_dump(mode="json") + if writer is None: + cols = sorted(fields) if fields else list(row.keys()) + writer = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore") + writer.writeheader() + writer.writerow({key: MongoDbRepository._csv_cell(value) for key, value in row.items()}) + yield buf.getvalue().encode() + buf.seek(0) + buf.truncate(0) + + async def _s3_object_exists(self, bucket_name: str, key_name: str, s3: AbstractAsyncContextManager[S3Client]): + async with s3 as s3_client: + try: + await s3_client.head_object(Bucket=bucket_name, Key=key_name) + return True + except Exception: + return False + + async def download( + self, + format: DownloadFormat, + short_mime: ShortMimeFormat, + ignore_cache: bool, + filter: TFilter, + fields: frozenset[str] | None, + s3: AbstractAsyncContextManager[S3Client], + bucket_name: str, + key_name: str, + restrict_ids: Iterable[Any] | None = None, + ) -> AsyncIterable[bytes]: + # Hash parameters to generate key for cache + payload = { + "format": format, + "short_mime": short_mime, + "filter": filter.model_dump(), + "fields": sorted(fields) if fields else None, + } + _ = self._hash_payload(payload) + + # TODO: S3 download cache. When implemented, this should `await + # self._s3_object_exists(...)` and stream the cached object on a hit. + + # Build from MongoDB (and, in future, save to cache) + query = filter.filter(self.document_model.find(self._scope)) + if restrict_ids is not None: + query = query.find(In(self.document_model.id, list(restrict_ids))) + query = filter.sort(query) + + serializer = self._get_serializer(format, fields) + + # Compress using gzip level 9 and stream out + compressor = zlib.compressobj(9, zlib.DEFLATED, 16 + zlib.MAX_WBITS) + + async def rows() -> AsyncIterator[TOut]: + async for table in query: + # TODO: We might think about skipping validation to save time + yield self.out_model.model_validate(table, from_attributes=True) + + async for line in serializer(rows()): + chunk = compressor.compress(line) + if chunk: + yield chunk + + # Flush the remaining buffered bytes and the gzip footer + # Without this the stream is a truncated gzip that cannot be decompressed. + tail = compressor.flush() + if tail: + yield tail diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py new file mode 100644 index 000000000..d3ce92337 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py @@ -0,0 +1,167 @@ +from collections.abc import AsyncIterable +from contextlib import AbstractAsyncContextManager + +from fastapi_filter.contrib.beanie import Filter +from pydantic import BaseModel +from pymongo.asynchronous.client_session import AsyncClientSession +from types_aiobotocore_s3 import S3Client + +from mpcontribs_api.domains._shared.components import MongoDbComponentsRepository +from mpcontribs_api.domains._shared.models import Component, ComponentDeleteResponse, ComponentIn, DocumentOut +from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.exceptions import NotFoundError +from mpcontribs_api.pagination import CursorParams, Page + + +class ComponentService[ + TDoc: Component, + TIn: ComponentIn, + TOut: DocumentOut, + TFilter: Filter, + TPatch: BaseModel, +]: + """Service layer for all shared component logic. + + Components (attachments, structures, tables) share the same access model and CRUD surface, so a + single configurable service handles every domain rather than a per-domain subclass. Each domain + is distinguished only by: + + - ``ref_field``: the field on a contribution that references this component type + (``"attachments"`` / ``"structures"`` / ``"tables"``) + - ``bucket_name``: the S3 bucket downloads are cached in (defaults to ``ref_field``) + + Reads, inserts, patches, and downloads forward to the components repository. Deletion is the only + operation with cross-repository logic, applying two gates: + + 1. **Access (scoped):** candidates are restricted to components reachable via a contribution + in the user's scope. A component the user cannot reach is treated as not found. + 2. **Integrity (global):** any reachable candidate still referenced by *any* contribution is + skipped; the rest are deleted. + """ + + def __init__( + self, + components: MongoDbComponentsRepository[TDoc, TIn, TOut, TFilter, TPatch], + contributions: MongoDbContributionRepository, + *, + ref_field: str, + bucket_name: str | None = None, + ) -> None: + self._components = components + self._contributions = contributions + self._ref_field = ref_field + self._bucket_name = bucket_name or ref_field + + async def get_many( + self, + filter: TFilter, + pagination: CursorParams, + fields: frozenset[str] | None, + ) -> Page[TOut]: + """Return a page of components reachable via an in-scope contribution. + + Components have no independent access field, so visibility is gated by contribution + reachability: results are restricted to ids referenced by a contribution the caller is + allowed to see + """ + allowed = await self._contributions.list_referenced_component_ids(self._ref_field, scoped=True) + return await self._components.get_many( + pagination=pagination, filter=filter, fields=fields, restrict_ids=allowed + ) + + async def get_by_id(self, id: str, fields: frozenset[str] | None) -> TDoc | TOut | None: + """Find a single component by id, gated by contribution reachability. + + Returns ``None`` (treated as not found) when no in-scope contribution references the id, + so callers cannot read a component belonging to a contribution they cannot see. + """ + oid = self._components._convert_object_id(id) + if not await self._contributions.referenced_component_ids(self._ref_field, [oid], scoped=True): + return None + return await self._components.get_component_by_id(id, fields) + + async def insert( + self, + components: list[TIn], + session: AsyncClientSession | None = None, + ) -> list[TDoc]: + """Bulk-insert components, deduplicated by content hash. See ``insert_components``.""" + return await self._components.insert_components(components=components, session=session) + + async def patch_by_id(self, id: str, update: TPatch) -> TDoc: + """Partially update a component by id, gated by contribution reachability. + + Raises: + NotFoundError: when no in-scope contribution references the id + """ + oid = self._components._convert_object_id(id) + if not await self._contributions.referenced_component_ids(self._ref_field, [oid], scoped=True): + raise NotFoundError(self._components._not_found(id)) + return await self._components.patch_component_by_id(id=id, update=update) + + async def download( + self, + format: DownloadFormat, + short_mime: ShortMimeFormat, + ignore_cache: bool, + filter: TFilter, + fields: frozenset[str] | None, + s3: AbstractAsyncContextManager[S3Client], + ) -> AsyncIterable[bytes]: + """Stream a gzip-compressed export of matching components. See ``download``.""" + allowed = await self._contributions.list_referenced_component_ids(self._ref_field, scoped=True) + return self._components.download( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=fields, + s3=s3, + bucket_name=self._bucket_name, + key_name="", # TODO: Temp + restrict_ids=allowed, + ) + + async def delete(self, filter: TFilter) -> ComponentDeleteResponse: + """Delete components matching ``filter`` that are reachable and globally unreferenced. + + Args: + filter (TFilter): the component-specific query to apply + + Returns: + ComponentDeleteResponse: count deleted, plus the ids skipped because a contribution + still references them + """ + candidate_ids = await self._components.list_ids(filter) + reachable = await self._contributions.referenced_component_ids(self._ref_field, candidate_ids, scoped=True) + if not reachable: + return ComponentDeleteResponse(num_deleted=0) + referenced = await self._contributions.referenced_component_ids(self._ref_field, list(reachable), scoped=False) + deletable = [cid for cid in reachable if cid not in referenced] + num_deleted = (await self._components.delete_by_ids(deletable)).num_deleted if deletable else 0 + return ComponentDeleteResponse( + num_deleted=num_deleted, + num_skipped=len(referenced), + referenced_ids=sorted(referenced), + ) + + async def delete_by_id(self, id: str) -> ComponentDeleteResponse: + """Delete a single component by id, subject to the access and integrity gates. + + Args: + id (str): the str representation of the component's ObjectId + + Returns: + ComponentDeleteResponse: the deletion result, or a skipped result if still referenced + + Raises: + NotFoundError: if the component is not reachable via any in-scope contribution + """ + oid = self._components._convert_object_id(id) + if not await self._contributions.referenced_component_ids(self._ref_field, [oid], scoped=True): + raise NotFoundError(self._components._not_found(id)) + if await self._contributions.referenced_component_ids(self._ref_field, [oid], scoped=False): + return ComponentDeleteResponse(num_deleted=0, num_skipped=1, referenced_ids=[oid]) + deleted = await self._components.delete_by_id(oid) + return ComponentDeleteResponse(num_deleted=deleted.num_deleted) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/types.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/types.py new file mode 100644 index 000000000..bfaf0e4a1 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/types.py @@ -0,0 +1,110 @@ +import re +from enum import StrEnum +from typing import Annotated + +import polars as pl +from fastapi import Query +from pydantic import BeforeValidator, Field, PlainSerializer, WithJsonSchema + +from mpcontribs_api.exceptions import ValidationError + +ShortStr = Annotated[str, Field(min_length=3, max_length=30)] + +FieldSelector = Annotated[list[str] | None, Query(alias="_fields")] + +_EMAIL_RE = re.compile(r"^[^:@\s]+:[^:@\s]+@[^@\s]+\.[^@\s]+$") + + +def _validate_prefixed_email(v: str) -> str: + v = v.strip() + if not _EMAIL_RE.match(v): + raise ValidationError("must match ':@', e.g. 'google:name@gmail.com'") + return v + + +PrefixedEmail = Annotated[str, BeforeValidator(_validate_prefixed_email)] + + +def _file_name_like_str(v: str) -> str: + v = v.strip() + parts = v.split(".") + if len(parts) > 1 and parts[-1]: + return v + raise ValidationError(f"attachment name '{v}' not valid. Must end with file extension (e.g. '.gz')") + + +FileLike = Annotated[str, BeforeValidator(_file_name_like_str)] + + +_MD5 = re.compile(r"^[a-f0-9]{32}$") + + +def _md5_like(v: str) -> str: + v = v.strip().lower() + if not _MD5.match(v): + raise ValidationError("must be a 32-character MD5 hex digest", md5=v) + return v + + +MD5Hash = Annotated[str, BeforeValidator(_md5_like)] + + +def _mime_like(v: str) -> str: + v = v.strip().lower() + parts = v.split("/") + if len(parts) == 2 and parts[0] == "application" and parts[1].strip(): + return v + raise ValidationError(f"improper mime value {v} - must be formatted as 'application/*file_ext*'") + + +MimeFormat = Annotated[str, BeforeValidator(_mime_like)] + + +class DownloadFormat(StrEnum): + JSONL = "jsonl" + CSV = "csv" + + +class ShortMimeFormat(StrEnum): + GZ = "gz" + + +# Not exactly a type, but used to coerce a str to a desired format (pseudo-type) +def download_filename(resource: str, format: DownloadFormat, short_mime: ShortMimeFormat) -> str: + """Build a download filename reflecting the resource, payload format, and compression. + + e.g. ``download_filename("contributions", DownloadFormat.CSV, ShortMimeFormat.GZ)`` + -> ``"contributions.csv.gz"``. + """ + return f"{resource}.{format.value}.{short_mime.value}" + + +def _coerce_frame(v: object) -> pl.DataFrame: + if isinstance(v, pl.DataFrame): + return v + if isinstance(v, dict): + return pl.DataFrame(v) + raise ValueError(f"cannot coerce {type(v)} to pl.DataFrame") + + +def _serialize_frame(data: pl.DataFrame) -> dict: + return data.to_dict(as_series=False) + + +# Beanie/pymongo would otherwise BSON-encode a pl.DataFrame by iterating it into bare column +# lists, dropping the column names and the dict shape the Pydantic serializer produces — which +# `_coerce_frame` cannot read back. Registering this on a Document's Settings.bson_encoders makes +# the stored form match the serialized form, so frames round-trip losslessly. +FRAME_BSON_ENCODERS = {pl.DataFrame: _serialize_frame} + + +PolarsFrame = Annotated[ + pl.DataFrame, + BeforeValidator(_coerce_frame), + PlainSerializer(_serialize_frame, return_type=dict), + WithJsonSchema( + {"type": "array", "items": {"type": "array", "items": {"type": "number"}}}, + mode="validation", + ), + WithJsonSchema({"type": "object"}, mode="serialization"), +] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/attachments/dependencies.py b/mpcontribs-api/src/mpcontribs_api/domains/attachments/dependencies.py new file mode 100644 index 000000000..966284a52 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/attachments/dependencies.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import Depends + +from mpcontribs_api.dependencies import UserDep +from mpcontribs_api.domains._shared.service import ComponentService +from mpcontribs_api.domains.attachments.models import ( + Attachment, + AttachmentFilter, + AttachmentIn, + AttachmentOut, + AttachmentPatch, +) +from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository + +AttachmentService = ComponentService[Attachment, AttachmentIn, AttachmentOut, AttachmentFilter, AttachmentPatch] + + +def get_attachment_service(user: UserDep) -> AttachmentService: + return ComponentService( + MongoDbAttachmentRepository(user), + MongoDbContributionRepository(user), + ref_field="attachments", + ) + + +AttachmentServiceDep = Annotated[AttachmentService, Depends(get_attachment_service)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/attachments/models.py b/mpcontribs-api/src/mpcontribs_api/domains/attachments/models.py new file mode 100644 index 000000000..4d7375837 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/attachments/models.py @@ -0,0 +1,90 @@ +from beanie import PydanticObjectId +from pydantic import field_validator + +from mpcontribs_api.domains._shared.filters import BaseFilter +from mpcontribs_api.domains._shared.models import Component, ComponentIn, DocumentOut +from mpcontribs_api.domains._shared.types import FileLike, MD5Hash, MimeFormat +from mpcontribs_api.exceptions import ValidationError +from mpcontribs_api.projection import SparseFieldsModel + +ACCEPTED_FORMATS = ["jpg", "jpeg", "png", "csv", "parquet", "gz"] + + +def _validate_attachment_name(v: str) -> str: + parts = v.strip().split(".") + if parts[-1].lower() not in ACCEPTED_FORMATS: + raise ValidationError( + f"Attachment extension not in allowed formats: {ACCEPTED_FORMATS}", + found_extension=parts[-1], + ) + return v + + +class Attachment(Component): + hash_fields = frozenset({"mime", "content"}) + mime: MimeFormat + content: int + + class Settings: + name = "attachments" + + @field_validator("name", mode="before") + @classmethod + def _name_with_extension(cls, v: str) -> str: + return _validate_attachment_name(v) + + +class AttachmentIn(ComponentIn): + """User-supplied attachment content. ``_id`` and ``md5`` are server-assigned, so absent here.""" + + mime: MimeFormat + content: int + + @field_validator("name", mode="before") + @classmethod + def _name_with_extension(cls, v: str) -> str: + return _validate_attachment_name(v) + + +class AttachmentOut(DocumentOut[PydanticObjectId]): + name: FileLike | None = None + md5: MD5Hash | None = None + mime: MimeFormat | None = None + content: int | None = None + + @staticmethod + def default_fields() -> list[str]: + # Light default; content fetched via ?_fields=. + return ["id", "name", "md5", "mime"] + + +class AttachmentPatch(SparseFieldsModel): + name: FileLike | None = None + mime: MimeFormat | None = None + content: int | None = None + + +class AttachmentFilter(BaseFilter): + id: PydanticObjectId | None = None + id__in: list[PydanticObjectId] | None = None + id__neq: PydanticObjectId | None = None + + md5: MD5Hash | None = None + md5__in: list[MD5Hash] | None = None + md5__neq: MD5Hash | None = None + + name: str | None = None + name__in: list[str] | None = None + name__neq: str | None = None + name__ilike: str | None = None + + mime: MimeFormat | None = None + mime__in: list[MimeFormat] | None = None + mime__neq: MimeFormat | None = None + mime__ilike: MimeFormat | None = None + + # sorting + order_by: list[str] | None = None + + class Constants(BaseFilter.Constants): + model = Attachment diff --git a/mpcontribs-api/src/mpcontribs_api/domains/attachments/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/attachments/repository.py new file mode 100644 index 000000000..6297b117f --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/attachments/repository.py @@ -0,0 +1,15 @@ +from mpcontribs_api.domains._shared.components import MongoDbComponentsRepository +from mpcontribs_api.domains.attachments.models import ( + Attachment, + AttachmentFilter, + AttachmentIn, + AttachmentOut, + AttachmentPatch, +) + + +class MongoDbAttachmentRepository( + MongoDbComponentsRepository[Attachment, AttachmentIn, AttachmentOut, AttachmentFilter, AttachmentPatch] +): + document_model = Attachment + out_model = AttachmentOut diff --git a/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py b/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py new file mode 100644 index 000000000..09214a4fa --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py @@ -0,0 +1,77 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from fastapi_filter import FilterDepends + +from mpcontribs_api.dependencies import S3Dep, require_user +from mpcontribs_api.domains._shared.models import ComponentDeleteResponse +from mpcontribs_api.domains._shared.types import ( + DownloadFormat, + FieldSelector, + ShortMimeFormat, + download_filename, +) +from mpcontribs_api.domains.attachments.dependencies import AttachmentServiceDep +from mpcontribs_api.domains.attachments.models import AttachmentFilter, AttachmentOut +from mpcontribs_api.pagination import CursorParams + +router = APIRouter() + + +@router.get("") +async def get_attachments( + service: AttachmentServiceDep, + pagination: Annotated[CursorParams, Depends()], + filter: AttachmentFilter = FilterDepends(AttachmentFilter), + fields: FieldSelector = AttachmentOut.default_fields(), +): + selected = AttachmentOut.parse_fields(fields) + return await service.get_many(filter=filter, fields=selected, pagination=pagination) + + +@router.get("/{pk}") +async def get_attachment( + service: AttachmentServiceDep, + pk: str, + fields: FieldSelector = AttachmentOut.default_fields(), +): + selected = AttachmentOut.parse_fields(fields) + return await service.get_by_id(id=pk, fields=selected) + + +@router.get("/download/{short_mime}") +async def download_attachment( + service: AttachmentServiceDep, + format: DownloadFormat, + s3: S3Dep, + short_mime: ShortMimeFormat = ShortMimeFormat.GZ, + ignore_cache: bool = False, + filter: AttachmentFilter = FilterDepends(AttachmentFilter), + fields: FieldSelector = AttachmentOut.default_fields(), +) -> StreamingResponse: + selected = AttachmentOut.parse_fields(fields) + body = await service.download( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=selected, + s3=s3, + ) + filename = download_filename("attachments", format, short_mime) + return StreamingResponse( + body, + media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_attachments(service: AttachmentServiceDep, filter: AttachmentFilter = FilterDepends(AttachmentFilter)): + return await service.delete(filter=filter) + + +@router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_attachment_by_id(service: AttachmentServiceDep, id: str): + return await service.delete_by_id(id=id) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/dependencies.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/dependencies.py new file mode 100644 index 000000000..805d4357a --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/dependencies.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from fastapi import Depends + +from mpcontribs_api.dependencies import MongoClientDep, UserDep +from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository +from mpcontribs_api.domains.contributions.repository import ( + MongoDbContributionRepository, +) +from mpcontribs_api.domains.contributions.service import ContributionService +from mpcontribs_api.domains.projects.repository import MongoDbProjectRepository +from mpcontribs_api.domains.structures.repository import MongoDbStructureRepository +from mpcontribs_api.domains.tables.repository import MongoDbTableRepository + + +def get_scoped_contributions(user: UserDep) -> MongoDbContributionRepository: + return MongoDbContributionRepository(user) + + +ContributionDep = Annotated[MongoDbContributionRepository, Depends(get_scoped_contributions)] + + +def get_contribution_service(user: UserDep, client: MongoClientDep) -> ContributionService: + return ContributionService( + client=client, + user=user, + projects=MongoDbProjectRepository(user), + contributions=MongoDbContributionRepository(user), + structures=MongoDbStructureRepository(user), + attachments=MongoDbAttachmentRepository(user), + tables=MongoDbTableRepository(user), + ) + + +ContributionServiceDep = Annotated[ContributionService, Depends(get_contribution_service)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/models.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/models.py new file mode 100644 index 000000000..8c4ceadfe --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/models.py @@ -0,0 +1,266 @@ +import re +from datetime import UTC, datetime +from typing import Annotated, Any +from warnings import deprecated + +from beanie import ( + Insert, + Link, + PydanticObjectId, + Replace, + Save, + SaveChanges, + Update, + before_event, +) +from bson.errors import InvalidId +from fastapi_filter import FilterDepends, with_prefix +from pydantic import BeforeValidator, Field, field_validator +from pymongo import ASCENDING, IndexModel + +from mpcontribs_api.domains._shared.filters import BaseFilter +from mpcontribs_api.domains._shared.models import BaseDocumentWithInput, DocumentOut +from mpcontribs_api.domains._shared.types import ShortStr +from mpcontribs_api.domains.attachments.models import Attachment, AttachmentFilter, AttachmentIn +from mpcontribs_api.domains.structures.models import Structure, StructureFilter, StructureIn +from mpcontribs_api.domains.tables.models import Table, TableFilter, TableIn +from mpcontribs_api.exceptions import ValidationError +from mpcontribs_api.projection import SparseFieldsModel + + +def _get_dict_depth(x) -> int: + if isinstance(x, dict): + return 1 + max((_get_dict_depth(v) for v in x.values()), default=0) + elif isinstance(x, list): + return max((_get_dict_depth(item) for item in x), default=0) + return 0 + + +def _validate_data_depth(data: dict[str, Any] | None) -> dict[str, Any] | None: + if data is None: + return None + depth = _get_dict_depth(data) + if depth > 7: + raise ValidationError("Depth of Contribution.data must be <= 7.", depth=depth) + return data + + +# Forbid punctuation, excluding: '*', '/' and exactly 1 '|' anywhere in string +_DATA_PUNCTUATION_PATTERN = re.compile(r"(?![^|]*\|[^|]*\|)[^\x21-\x29\x2B-\x2E\x3A-\x40\x5B-\x5E\x60\x7B\x7D-\x7E]*") + + +def _validate_keys(data: dict[str, Any] | None) -> dict[str, Any] | None: + if data is None: + return None + keys = list(data.keys()) + if not all(isinstance(k, str) and k.isascii() for k in keys): + raise ValidationError("Non-ASCII key found in Contribution.data. All dict keys must be only ASCII") + if any(k == "" for k in keys): + raise ValidationError("Empty key found in Contribution.data. Keys must be non-empty.") + if any(_DATA_PUNCTUATION_PATTERN.fullmatch(k) is None for k in keys): + raise ValidationError( + "Punctuation found in Contribution.data keys. Only '_', '*', '/', and at most 1 '|' permitted." + ) + # Recurse into nested dicts, including dicts nested inside lists. + for v in data.values(): + _validate_nested_keys(v) + return data + + +def _validate_nested_keys(value: Any) -> None: + if isinstance(value, dict): + _validate_keys(value) + elif isinstance(value, list): + for item in value: + _validate_nested_keys(item) + + +class ContributionBase(BaseDocumentWithInput[PydanticObjectId]): + """Shared settings and fields for Contribution, ContributionIn, and ContributionOut.""" + + project: str + identifier: str + formula: str + is_public: bool = False + data: Annotated[ + dict[str, Any], + BeforeValidator(_validate_data_depth), + BeforeValidator(_validate_keys), + ] + + last_modified: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + class Settings: + name = "contributions" + keep_nulls = False + indexes = [ + IndexModel( + keys=[("project", ASCENDING), ("identifier", ASCENDING), ("version", ASCENDING)], + name="project_identifier_version", + unique=True, + ), + # Multikey indexes over each Link field's DBRef id so the component-delete + # reference check (referenced_component_ids) is index-served, not a COLLSCAN. + IndexModel(keys=[("structures.$id", ASCENDING)], name="ref_structures"), + IndexModel(keys=[("tables.$id", ASCENDING)], name="ref_tables"), + IndexModel(keys=[("attachments.$id", ASCENDING)], name="ref_attachments"), + ] + + +class Contribution(ContributionBase): + """Models what is actually stored in the database.""" + + # Server-owned: the service resolves the real version (see ContributionService._split_non_unique) + # and stamps it on the doc. Defaults to 1 so the no-version (unique-identifier) case is implicit. + version: int = 1 + structures: list[Link[Structure]] | None = None + tables: list[Link[Table]] | None = None + attachments: list[Link[Attachment]] | None = None + needs_build: Annotated[bool | None, deprecated("'needs_build' is deprecated.")] = False + + @classmethod + def from_input_model(cls, data: ContributionIn) -> Contribution: + # Server-owned fields are not taken from input: is_public starts False, components are + # inserted separately, last_modified is stamped by the before_event hook, and version is + # resolved/stamped by the service (never trusted from the request body). + return cls.model_validate( + { + **data.model_dump( + exclude={"is_public", "version", "structures", "tables", "attachments", "last_modified"} + ), + "is_public": False, + } + ) + + @before_event(Insert, Replace, Update, Save, SaveChanges) + def set_last_modified(self): + self.last_modified = datetime.now(UTC) + + +class ContributionIn(ContributionBase): + """Fields that users are allowed to submit when adding a Contribution. + + version will be inferred if left as None + """ + + # Only meaningful on upsert/update of a non-unique-identifier project, where it selects which + # version to target. Ignored on insert (the service auto-assigns) and for unique-identifier + # projects (inferred as 1). + version: int | None = None + structures: list[StructureIn] | None = None + tables: list[TableIn] | None = None + attachments: list[AttachmentIn] | None = None + + def has_components(self) -> bool: + """Returns ``True`` if the contribution has any components (structures, tables, attachments)""" + return bool(self.structures or self.tables or self.attachments) + + def component_count(self) -> int: + """Returns the total number of components (structures, tables, attachments) in the contribution""" + return len(self.structures or []) + len(self.tables or []) + len(self.attachments or []) + + def identifiers(self) -> dict[str, str]: + """Returns a dict of unique identifiers for a contribution (outside of id).""" + return {"project": self.project, "identifier": self.identifier} + + +class ContributionOut(DocumentOut[PydanticObjectId]): + """Models what the users are allowed to see in a return. + + Users can specify further what they want to see if not everything is of interest + """ + + project: str | None = None + identifier: str | None = None + version: int | None = None + formula: str | None = None + is_public: bool | None = None + last_modified: datetime | None = None + needs_build: Annotated[bool | None, deprecated("'needs_build' is deprecated.")] = None + # No input validators on the read path: stored documents are trusted, and re-validating here + # would 500 on historical data that missed the correction (see carrier_transport contribs) + data: dict[str, Any] | None = None + structures: list[Link[Structure]] | None = None + tables: list[Link[Table]] | None = None + attachments: list[Link[Attachment]] | None = None + + @staticmethod + def default_fields() -> list[str]: + return [ + "id", + "project", + "identifier", + "version", + "formula", + "is_public", + "last_modified", + ] + + +class ContributionPatch(SparseFieldsModel): + """Fields that can be specified for partial updates to a Contribution.""" + + project: str | None = None + identifier: str | None = None + version: int | None = None + formula: str | None = None + is_public: bool | None = None + data: Annotated[ + dict[str, Any] | None, + BeforeValidator(_validate_data_depth), + BeforeValidator(_validate_keys), + ] = None + structures: list[Link[Structure]] | None = None + tables: list[Link[Table]] | None = None + attachments: list[Link[Attachment]] | None = None + + +class ContributionFilter(BaseFilter): + """How users can filter searches for Contributions. + + Includes filters for linked documents (Components) + """ + + id: PydanticObjectId | None = None + id__in: list[PydanticObjectId] | None = None + id__neq: PydanticObjectId | None = None + + identifier: str | None = None + identifier__in: list[ShortStr] | None = None + identifier__neq: ShortStr | None = None + identifier__ilike: str | None = None + + version: str | None = None + version__in: list[ShortStr] | None = None + version__neq: ShortStr | None = None + version__ilike: str | None = None + + formula: str | None = None + formula__in: list[ShortStr] | None = None + formula__neq: ShortStr | None = None + formula__ilike: str | None = None + + is_public: bool | None = None + + needs_build: bool | None = None + + table: TableFilter | None = FilterDepends(with_prefix("tables", TableFilter)) + attachment: AttachmentFilter | None = FilterDepends(with_prefix("attachments", AttachmentFilter)) + structure: StructureFilter | None = FilterDepends(with_prefix("structures", StructureFilter)) + + # sorting + order_by: list[str] | None = None + + class Constants(BaseFilter.Constants): + model = Contribution + + @field_validator("id", mode="before") + @classmethod + def convert_str_to_oid(cls, v: str): + try: + return PydanticObjectId(v) + except InvalidId as err: + raise ValidationError( + "Invalid ObjectId format. Must be 12-byte input or a 24-character hex string", + oid=v, + ) from err diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py new file mode 100644 index 000000000..09eb3e5f3 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py @@ -0,0 +1,308 @@ +from collections.abc import AsyncIterable +from contextlib import AbstractAsyncContextManager +from typing import Any + +from beanie import PydanticObjectId, UpdateResponse +from beanie.operators import Set +from pymongo.asynchronous.client_session import AsyncClientSession +from pymongo.results import DeleteResult +from types_aiobotocore_s3 import S3Client + +from mpcontribs_api.authz import User +from mpcontribs_api.domains._shared.repository import MongoDbRepository +from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat +from mpcontribs_api.domains.contributions.models import ( + Contribution, + ContributionFilter, + ContributionIn, + ContributionOut, + ContributionPatch, +) +from mpcontribs_api.pagination import CursorParams + + +class MongoDbContributionRepository( + MongoDbRepository[Contribution, ContributionIn, ContributionOut, ContributionFilter, ContributionPatch] +): + """A repository layer for access to MongoDB. + + Shared CRUD logic lives on :class:`MongoDbRepository`; the methods here are domain-named + forwarders that give routers a consistent vocabulary and concrete types, plus the operations + whose shape is contribution-specific (filtered delete, id-keyed upsert, download). + Multi-collection orchestration (component inserts) lives in ``ContributionService``. + """ + + document_model = Contribution + out_model = ContributionOut + + @staticmethod + def _build_scope(user: User) -> dict[str, Any]: + """Provides scope based on current user's permitted groups and publicly released data.""" + if user.is_admin: + return {} + ors: list[dict[str, Any]] = [{"is_public": True}] + if user.writable_projects: + ors.append({"project": {"$in": sorted(user.writable_projects)}}) + return {"$or": ors} + + async def get_contributions( + self, + filter: ContributionFilter, + pagination: CursorParams | None = None, + fields: frozenset[str] | None = None, + ): + """Query the Contribution collection, scoped to the current user. See ``get_many``.""" + return await self.get_many(pagination=pagination, filter=filter, fields=fields) + + async def get_contribution_by_id(self, id: str, fields: frozenset[str] | None): + """Find a single contribution by id, scoped to the current user. See ``get_by_id``.""" + return await self.get_by_id(self._convert_object_id(id), fields) + + async def patch_contribution_by_id(self, id: str, update: ContributionPatch): + """Partially update a contribution by id, scoped to the current user. See ``patch``.""" + return await self.patch(self._convert_object_id(id), update) + + async def delete_contribution_by_id(self, id: str) -> None: + """Delete a contribution by id, scoped to the current user. See ``delete_by_id``.""" + await self.delete_by_id(self._convert_object_id(id)) + + async def delete_contributions( + self, + filter: ContributionFilter, + ) -> DeleteResult | None: + """Bulk deletion of Contributions described by the filter. + + Args: + filter (ContribtionFilter): the filter to use to identify contributions to delete + """ + return await filter.filter(self.document_model.find(self._scope)).delete_many() + + async def insert_many_contributions( + self, + docs: list[Contribution], + session: AsyncClientSession | None = None, + ): + """Bulk-insert pre-built Contribution documents. + + Used by the ``ContributionService`` no-component fast path. On partial failure pymongo + raises ``BulkWriteError`` whose ``details["writeErrors"]`` carries per-index error info + that the service maps back into a ``BulkWriteSummary``. + """ + return await self.document_model.insert_many(docs, ordered=False, session=session) + + async def insert_contribution( + self, + doc: Contribution, + session: AsyncClientSession | None = None, + ) -> Contribution: + """Insert a single pre-built Contribution document, optionally in a transaction.""" + await doc.insert(session=session) + return doc + + async def find_one_contribution(self, project: str, identifier: str) -> Contribution | None: + """Find a single contribution by (project, identifier), scoped to the current user.""" + return await self.document_model.find_one( + self._scope, + self.document_model.project == project, + self.document_model.identifier == identifier, + ) + + async def max_versions(self, keys: list[tuple[str, str]]) -> dict[tuple[str, str], int]: + """Return ``{(project, identifier): max_version}`` for the given keys, scoped to the user. + + Presence of a key in the result also signals that at least one contribution already exists + for it, which the contribution write path uses to enforce uniqueness on unique-identifier + projects and to compute the next version on non-unique ones. Keys with no existing + contributions are absent from the result. + + A single aggregation answers the whole batch so the write path avoids one round-trip per + contribution. Scope is merged into ``$match`` (mirroring :meth:`referenced_component_ids`); + a writer sees every contribution in their own project, so the scoped max equals the global + max for keys they may write. + + Args: + keys: (project, identifier) pairs to look up + + Returns: + dict[tuple[str, str], int]: highest existing version per requested key + """ + if not keys: + return {} + match: dict[str, Any] = {"$or": [{"project": p, "identifier": i} for p, i in keys]} + if self._scope: + match = {"$and": [self._scope, match]} + pipeline: list[dict[str, Any]] = [ + {"$match": match}, + { + "$group": { + "_id": {"project": "$project", "identifier": "$identifier"}, + "max_version": {"$max": "$version"}, + } + }, + ] + collection = self.document_model.get_pymongo_collection() + result: dict[tuple[str, str], int] = {} + async for doc in await collection.aggregate(pipeline): + gid = doc["_id"] + # Versions are >= 1; coalesce a null $max (legacy docs without the field) to 0 while + # still recording the key's presence (existence check for unique-identifier projects). + result[(gid["project"], gid["identifier"])] = doc.get("max_version") or 0 + return result + + async def referenced_component_ids( + self, + ref_field: str, + ids: list[PydanticObjectId], + *, + scoped: bool, + ) -> set[PydanticObjectId]: + """Return the subset of ``ids`` referenced by contributions through ``ref_field``. + + Beanie stores each ``Link`` as a DBRef (``{"$ref": ..., "$id": ObjectId}``), so a + component is referenced when its id appears under ``.$id`` on any matching + contribution. + + Args: + ref_field: the contribution link field to inspect ("structures" | "tables" | + "attachments"). Always a fixed class-attr at the call site, never user input. + ids: candidate component ids to test + scoped: when ``True`` the user scope is applied (access gate / reachability); when + ``False`` the check spans every contribution (global integrity check) + + Returns: + set[PydanticObjectId]: the ids in ``ids`` that are still referenced + """ + if not ids: + return set() + key = f"{ref_field}.$id" + query: dict[str, Any] = {key: {"$in": ids}} + if scoped and self._scope: + query = {"$and": [self._scope, query]} + target = set(ids) + referenced: set[PydanticObjectId] = set() + collection = self.document_model.get_pymongo_collection() + async for doc in collection.find(query, {ref_field: 1}): + for ref in doc.get(ref_field) or []: + rid = ref.id if hasattr(ref, "id") else ref.get("$id") + if rid in target: + referenced.add(rid) + return referenced + + # TODO: should return document with update + async def list_referenced_component_ids( + self, + ref_field: str, + *, + scoped: bool, + ) -> set[PydanticObjectId]: + """Return every component id referenced through ``ref_field`` by matching contributions. + + Unlike :meth:`referenced_component_ids`, this takes no candidate list — it enumerates all + ids reachable from contributions in scope. + + Args: + ref_field: the contribution link field to inspect ("structures" | "tables" | + "attachments"). Always a fixed class-attr at the call site, never user input. + scoped: when ``True`` the user scope is applied (access gate); when ``False`` the + check spans every contribution. + + Returns: + set[PydanticObjectId]: all component ids referenced via ``ref_field`` + """ + key = f"{ref_field}.$id" + query: dict[str, Any] = {key: {"$exists": True}} + if scoped and self._scope: + query = {"$and": [self._scope, query]} + referenced: set[PydanticObjectId] = set() + collection = self.document_model.get_pymongo_collection() + async for doc in collection.find(query, {ref_field: 1}): + for ref in doc.get(ref_field) or []: + rid = ref.id if hasattr(ref, "id") else ref.get("$id") + if rid is not None: + referenced.add(rid) + return referenced + + async def update_contribution(self, doc: Contribution, update_data: dict[str, Any]) -> None: + """Apply a partial update to an existing Contribution document.""" + await doc.update(Set(update_data)) + + async def upsert_contribution_by_identifiers( + self, + identifiers: dict[str, str], + contribution: ContributionIn, + version: int, + ) -> Contribution: + """Atomically upsert a Contribution by its identifying fields and resolved version. + + Relies on the unique index over (project, identifier, version) so that concurrent requests + targeting the same key cannot both win the insert branch. Fields the caller did not set are + not touched (partial update). On insert a fresh Contribution document is written with + ``is_public=False``. + + Args: + identifiers: the fields ContributionIn.identifiers() returns (project, identifier) + contribution: the input payload to upsert + version: the version resolved by the service (1 for unique-identifier projects, or the + caller-supplied version for non-unique ones); selects which row to update + + Returns: + Contribution: the document as it stands after the operation + """ + doc = self.document_model.from_input_model(contribution) + doc.version = version + update_data = doc.model_dump(exclude={"id"}, exclude_none=True) + query = self.document_model.find_one( + self._scope, + self.document_model.project == identifiers["project"], + self.document_model.identifier == identifiers["identifier"], + self.document_model.version == version, + ).upsert( + Set(update_data), + on_insert=doc, + response_type=UpdateResponse.NEW_DOCUMENT, + ) + return await query # pyright: ignore[reportGeneralTypeIssues] # beanie UpdateQuery is awaitable, but pyright doesn't see it + + async def upsert_contribution_by_id(self, id: str, contribution: ContributionIn): + """Upserts a single Contribution. + + If Contributions with identical identifiers exist, update, otherwise insert + + Args: + id (str): the id of the Contribution to upsert + contribution (ContributionIn): the Contribution to be upserted + + Returns: + ContributionOut: the upserted document""" + doc = self.document_model.from_input_model(contribution) + query = self.document_model.find_one( + self._scope, + self.document_model.id == self._convert_object_id(id), + ).upsert( + Set(doc.model_dump(exclude={"id"}, exclude_none=True)), + on_insert=doc, + response_type=UpdateResponse.NEW_DOCUMENT, + ) + return await query # pyright: ignore[reportGeneralTypeIssues] # beanie UpdateQuery is awaitable, but pyright doesn't see it + + async def download_contributions( + self, + format: DownloadFormat, + short_mime: ShortMimeFormat, + ignore_cache: bool, + filter: ContributionFilter, + fields: frozenset[str] | None, + key_name: str, + s3: AbstractAsyncContextManager[S3Client], + bucket_name: str = "contributions", + ) -> AsyncIterable[bytes]: + return self.download( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=fields, + bucket_name=bucket_name, + key_name=key_name, + s3=s3, + ) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py new file mode 100644 index 000000000..73466eff2 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py @@ -0,0 +1,140 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from fastapi_filter import FilterDepends + +from mpcontribs_api.config import get_settings +from mpcontribs_api.dependencies import S3Dep, require_user +from mpcontribs_api.domains._shared.bulk import BulkWriteSummary +from mpcontribs_api.domains._shared.types import ( + DownloadFormat, + FieldSelector, + ShortMimeFormat, + download_filename, +) +from mpcontribs_api.domains.contributions.dependencies import ContributionDep, ContributionServiceDep +from mpcontribs_api.domains.contributions.models import ( + Contribution, + ContributionFilter, + ContributionIn, + ContributionOut, + ContributionPatch, +) +from mpcontribs_api.exceptions import ValidationError +from mpcontribs_api.pagination import CursorParams + +router = APIRouter() + + +def _enforce_bulk_limit(contributions: list[ContributionIn]) -> None: + """Reject a bulk write larger than the configured per-request ceiling. + + Guards the synchronous bulk endpoints so a single request can't hand the service an unbounded + list. Callers over the limit should chunk (the limit is advertised at ``GET /api/v1/limits``) + or use the async bulk ingestion endpoint. Complements the body-size middleware, which bounds + bytes rather than item count. + """ + limit = get_settings().mongo.bulk_write_limit + count = len(contributions) + if count > limit: + raise ValidationError( + f"Bulk request of {count} contributions exceeds the per-request limit of {limit}. " + "Chunk the request (see GET /api/v1/limits) or use the async bulk ingestion endpoint.", + count=count, + limit=limit, + ) + + +@router.get("") +async def get_contributions( + repo: ContributionDep, + pagination: Annotated[CursorParams, Depends()], + filter: ContributionFilter = FilterDepends(ContributionFilter), + fields: FieldSelector = ContributionOut.default_fields(), +): + selected = ContributionOut.parse_fields(fields) + return await repo.get_contributions(pagination=pagination, filter=filter, fields=selected) + + +@router.delete("", dependencies=[Depends(require_user)]) +async def delete_contributions( + repo: ContributionDep, + filter: ContributionFilter = FilterDepends(ContributionFilter), +): + return await repo.delete_contributions(filter=filter) + + +# TODO: Might want to take contributions in from request body and run model_validate_json on it (much faster) +@router.post("", response_model=BulkWriteSummary[Contribution], dependencies=[Depends(require_user)]) +async def insert_contributions( + service: ContributionServiceDep, + contributions: list[ContributionIn], +): + _enforce_bulk_limit(contributions) + return await service.insert_contributions(contributions=contributions) + + +@router.put("", response_model=BulkWriteSummary[Contribution], dependencies=[Depends(require_user)]) +async def upsert_contributions( + service: ContributionServiceDep, + contributions: list[ContributionIn], +): + _enforce_bulk_limit(contributions) + return await service.upsert_contributions(contributions=contributions) + + +@router.get("/download/{short_mime}") +async def download_contributions( + repo: ContributionDep, + s3: S3Dep, + short_mime: ShortMimeFormat = ShortMimeFormat.GZ, + format: DownloadFormat = DownloadFormat.JSONL, + ignore_cache: bool = False, + filter: ContributionFilter = FilterDepends(ContributionFilter), + fields: FieldSelector = ContributionOut.default_fields(), +): + selected = ContributionOut.parse_fields(fields) + body = await repo.download_contributions( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=selected, + s3=s3, + key_name="", # TODO: Temp + ) + filename = download_filename("contributions", format, short_mime) + return StreamingResponse( + body, + media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/{id}", dependencies=[Depends(require_user)]) +async def delete_contribution_by_id( + service: ContributionServiceDep, + id: str, +): + return await service.delete_contributions(ContributionFilter.model_validate({"id": id})) + + +@router.get("/{id}") +async def get_contribution_by_id( + repo: ContributionDep, + id: str, + fields: FieldSelector = ContributionOut.default_fields(), +): + selected = ContributionOut.parse_fields(fields) + return await repo.get_contribution_by_id(id=id, fields=selected) + + +@router.put("/{id}", dependencies=[Depends(require_user)]) +async def upsert_contribution_by_id(repo: ContributionDep, id: str, contribution: ContributionIn): + return await repo.upsert_contribution_by_id(id=id, contribution=contribution) + + +@router.patch("/{id}", dependencies=[Depends(require_user)]) +async def patch_contribution_by_id(repo: ContributionDep, id: str, update: ContributionPatch): + return await repo.patch_contribution_by_id(id=id, update=update) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py new file mode 100644 index 000000000..88ba3a523 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py @@ -0,0 +1,522 @@ +import asyncio +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from typing import cast + +import structlog +from beanie import Link, PydanticObjectId +from pymongo import AsyncMongoClient +from pymongo.asynchronous.client_session import AsyncClientSession +from pymongo.errors import BulkWriteError + +from mpcontribs_api.authz import User +from mpcontribs_api.config import MongoSettings, get_settings +from mpcontribs_api.domains._shared.bulk import ( + BulkDeleteSummary, + BulkFailure, + BulkWriteSummary, + bulk_failure_from_exception, +) +from mpcontribs_api.domains._shared.repository import MongoDbRepository +from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository +from mpcontribs_api.domains.contributions.models import Contribution, ContributionFilter, ContributionIn +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.domains.projects.repository import MongoDbProjectRepository +from mpcontribs_api.domains.structures.models import Structure +from mpcontribs_api.domains.structures.repository import MongoDbStructureRepository +from mpcontribs_api.domains.tables.models import Table +from mpcontribs_api.domains.tables.repository import MongoDbTableRepository +from mpcontribs_api.exceptions import AppError, ConflictError, PermissionError, ValidationError +from mpcontribs_api.pagination import CursorParams + +logger = structlog.get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class ResolvedWrite: + """A contribution that passed validation, paired with its server-resolved version. + + Produced by the split pipeline so the resolved version travels with its contribution (and its + original batch index) + """ + + index: int + contribution: ContributionIn + version: int + + +class ContributionService: + def __init__( + self, + client: AsyncMongoClient, + user: User, + projects: MongoDbProjectRepository, + contributions: MongoDbContributionRepository, + structures: MongoDbStructureRepository, + attachments: MongoDbAttachmentRepository, + tables: MongoDbTableRepository, + settings: MongoSettings | None = None, + ): + self._client = client + self._user = user + self._projects = projects + self._contributions = contributions + self._structures = structures + self._attachments = attachments + self._tables = tables + self._settings = settings or get_settings().mongo + + @property + def _children(self) -> dict[str, MongoDbRepository]: + return { + "structures": self._structures, + "attachments": self._attachments, + "tables": self._tables, + } + + async def insert_contributions( + self, + contributions: list[ContributionIn], + ) -> BulkWriteSummary[Contribution]: + """Atomic bulk insert contributions, atomically per top-level contribution. + + Contributions carrying no components are inserted in one ``insert_many`` (no transaction); + contributions with components run inside their own MongoDB transaction so the contribution + and its components commit or roll back together. Concurrent transactions are bounded by + ``settings.mongo.max_concurrent_transactions``. Per-item failures are returned in the + summary's ``failed`` list; the request as a whole does not raise on partial failure. + + Version is server-assigned per contribution: unique-identifier projects reject a duplicate + (project, identifier) as a conflict (version stays 1); non-unique projects auto-increment + from the current max. See ``_split_non_unique``. + + Args: + contributions: contributions to insert; may include nested structures/tables/attachments + + Returns: + BulkWriteSummary[Contribution]: per-item outcome, sized to ``len(contributions)`` + """ + if not contributions: + return BulkWriteSummary[Contribution](total=0, succeeded=[], failed=[]) + + failures, plan = await self._split_contributions(contributions, is_upsert=False) + no_comp = [item for item in plan if not item.contribution.has_components()] + with_comp = [item for item in plan if item.contribution.has_components()] + + no_comp_succeeded, no_comp_failed = await self._insert_no_components(no_comp) + with_comp_succeeded, with_comp_failed = await self._insert_with_components(with_comp) + + succeeded = [doc for _, doc in sorted(no_comp_succeeded + with_comp_succeeded, key=lambda p: p[0])] + failed = sorted( + failures + no_comp_failed + with_comp_failed, + key=lambda f: f.index, + ) + return BulkWriteSummary[Contribution](total=len(contributions), succeeded=succeeded, failed=failed) + + def _split_unauthorized( + self, + indices: Iterable[int], + contributions: list[ContributionIn], + ) -> tuple[list[BulkFailure], list[int]]: + """Reject contributions whose ``project`` the current user is not permitted to write. + + Authorized iff the user is an admin (writes anything) or the contribution's ``project`` is + one of the user's groups. Anonymous users are blocked at the router, but carry no groups and + are not admins, so they fall through to authorized-for-nothing here (defense-in-depth). + + Partitions ``indices`` into unauthorized ``BulkFailure`` entries and the remaining authorized + indices that should proceed. Mirrors ``_split_oversize`` (same shape) so callers can chain + the splits and keep each index in exactly one bucket, preserving input ordering. + """ + unauthorized: list[BulkFailure] = [] + remaining: list[int] = [] + for i in indices: + contrib = contributions[i] + if self._user.can_write(contrib.project): + remaining.append(i) + else: + unauthorized.append( + BulkFailure( + index=i, + identifier=contrib.identifiers(), + error_code=PermissionError.error_code, + message=f"not authorized to write to project '{contrib.project}'", + ) + ) + logger.warning( + "User attempted to add contributions to projects they are not authorized for.", + project=contrib.project, + ) + return unauthorized, remaining + + async def _split_non_unique( + self, + indices: Iterable[int], + contributions: list[ContributionIn], + *, + is_upsert: bool, + ) -> tuple[list[BulkFailure], list[ResolvedWrite]]: + """Apply per-project identifier-uniqueness rules and resolve each contribution's version. + + ``Project.unique_identifiers`` decides the contract per project: + + - **True**: at most one contribution per (project, identifier); version is always 1. On + insert a second one (already in the DB, or a duplicate earlier in this batch) is rejected + as a conflict. On upsert the version is inferred as 1 (any supplied value is ignored). + - **False**: many versions may share (project, identifier). On insert the version is + auto-assigned as ``max(existing) + 1``, sequencing intra-batch duplicates. On upsert the + caller must supply ``version`` to pick the target row, else the item is rejected. + + Iterates ``indices`` in input order so intra-batch duplicates sequence deterministically. + + Returns: + tuple of (rejections, a ``ResolvedWrite`` per survivor pairing it with its version) + """ + indices = list(indices) + failures: list[BulkFailure] = [] + plan: list[ResolvedWrite] = [] + if not indices: + return failures, plan + + # One round-trip each for the per-project uniqueness flags and (insert only) the current + # max version per key, instead of a query per contribution. + unique_map = await self._projects.unique_identifiers_by_id(sorted({contributions[i].project for i in indices})) + max_map: dict[tuple[str, str], int] = {} + if not is_upsert: + keys = sorted({(contributions[i].project, contributions[i].identifier) for i in indices}) + max_map = await self._contributions.max_versions(keys) + + seen: dict[tuple[str, str], int] = defaultdict(int) + for i in indices: + contrib = contributions[i] + key = (contrib.project, contrib.identifier) + + if contrib.project not in unique_map: + failures.append( + BulkFailure( + index=i, + identifier=contrib.identifiers(), + error_code=ValidationError.error_code, + message=f"project '{contrib.project}' not found or not accessible", + ) + ) + logger.info( + "project not found or not accessible", + project=contrib.project, + identifiers=contrib.identifiers(), + ) + continue + + unique = unique_map[contrib.project] + if is_upsert: + if unique: + version = 1 + elif contrib.version is not None: + version = contrib.version + else: + failures.append( + BulkFailure( + index=i, + identifier=contrib.identifiers(), + error_code=ValidationError.error_code, + message=( + f"project '{contrib.project}' allows multiple versions; a 'version' " + "is required to identify which contribution to update" + ), + ) + ) + logger.info( + "ambiguous contribution version in project allowing multiple versions", + project=contrib.project, + identifiers=contrib.identifiers(), + ) + continue + elif unique: + # Insert into a unique-identifier project: the first occurrence wins, anything that + # already exists (in the DB or earlier in this batch) is a conflict. + if key in max_map or seen[key] > 0: + failures.append( + BulkFailure( + index=i, + identifier=contrib.identifiers(), + error_code=ConflictError.error_code, + message=( + f"contribution '{contrib.identifier}' already exists for project '{contrib.project}'" + ), + ) + ) + logger.info( + "contribution already exists in project during insert/upsert/update", + project=contrib.project, + identifiers=contrib.identifiers(), + ) + continue + version = 1 + else: + # Insert into a non-unique-identifier project: next version after the current max, + # sequencing duplicates within this batch (max+1, max+2, ...). + version = max_map.get(key, 0) + 1 + seen[key] + + plan.append(ResolvedWrite(index=i, contribution=contrib, version=version)) + seen[key] += 1 + + return failures, plan + + def _split_oversize( + self, + indices: Iterable[int], + contributions: list[ContributionIn], + ) -> tuple[list[BulkFailure], list[int]]: + """Reject contributions whose component count exceeds the per-contribution ceiling. + + Partitions ``indices`` into oversize ``BulkFailure`` entries and the remaining indices that + should proceed to Mongo. Doing this upfront avoids burning a transaction slot on a request + guaranteed to exceed transactionLifetimeLimitSeconds. + """ + cap = self._settings.max_components_per_contribution + oversize: list[BulkFailure] = [] + remaining: list[int] = [] + for i in indices: + contrib = contributions[i] + count = contrib.component_count() + if count > cap: + oversize.append( + BulkFailure( + index=i, + identifier=contrib.identifiers(), + error_code=ValidationError.error_code, + message=f"contribution has {count} components, exceeds cap of {cap}. " + "Recommend inserting the component alone, followed by bulk inserts of components", + ) + ) + logger.info("Attemped to add contribution with too many components.", num_components=count, max=cap) + else: + remaining.append(i) + return oversize, remaining + + async def _split_contributions( + self, contributions: list[ContributionIn], *, is_upsert: bool + ) -> tuple[list[BulkFailure], list[ResolvedWrite]]: + """Common method for validating contribution write failure logic and resolving versions. + + Runs the cheap, local, index-based filters first (authorization, then component-count cap) + so guaranteed failures never reach the DB; ``_split_non_unique`` runs last and turns the + remaining indices into a write plan carrying each resolved version. + + Returns: + tuple of (failures and their reasons, a ``ResolvedWrite`` per contribution to write) + """ + # Per-item project authorization (see _split_unauthorized for the per-item vs fail-fast + # decision). Only authorized items reach Mongo; the rest are reported in ``failed``. + unauthorized_failures, authorized_indices = self._split_unauthorized(range(len(contributions)), contributions) + # Reject contributions that have too many components associated with them. + oversize_failures, sized_indices = self._split_oversize(authorized_indices, contributions) + # Verify identifiers/uniqueness within a project and resolve each version, depending on + # project.unique_identifiers and whether this is an insert or upsert. + non_unique_failures, plan = await self._split_non_unique(sized_indices, contributions, is_upsert=is_upsert) + return (unauthorized_failures + oversize_failures + non_unique_failures, plan) + + async def _insert_no_components( + self, + items: list[ResolvedWrite], + ) -> tuple[list[tuple[int, Contribution]], list[BulkFailure]]: + """Single-collection bulk insert for component-free contributions. + + Uses ``ordered=False`` so a single bad item doesn't sink the rest of the batch. pymongo + raises ``BulkWriteError`` with per-index error info on partial failure; we map that back + onto the original input indices. + """ + if not items: + return [], [] + docs = [] + for item in items: + doc = Contribution.from_input_model(item.contribution) + doc.version = item.version + docs.append(doc) + try: + await self._contributions.insert_many_contributions(docs) + return [(item.index, doc) for item, doc in zip(items, docs, strict=True)], [] + except BulkWriteError as exc: + return self._partition_bulk_write_error(items, docs, exc) + + @staticmethod + def _partition_bulk_write_error( + items: list[ResolvedWrite], + docs: list[Contribution], + exc: BulkWriteError, + ) -> tuple[list[tuple[int, Contribution]], list[BulkFailure]]: + """Map pymongo's per-position writeErrors back to the caller's original input indices.""" + write_errors = exc.details.get("writeErrors", []) if exc.details else [] + failed_positions = {err.get("index"): err for err in write_errors} + succeeded: list[tuple[int, Contribution]] = [] + failed: list[BulkFailure] = [] + for position, (item, doc) in enumerate(zip(items, docs, strict=True)): + err = failed_positions.get(position) + if err is None: + succeeded.append((item.index, doc)) + else: + failed.append( + BulkFailure( + index=item.index, + identifier=item.contribution.identifiers(), + error_code="conflict" if err.get("code") == 11000 else "write_error", + message=err.get("errmsg", "write failed"), + ) + ) + return succeeded, failed + + async def _insert_with_components( + self, + items: list[ResolvedWrite], + ) -> tuple[list[tuple[int, Contribution]], list[BulkFailure]]: + """Per-contribution transaction path, bounded by ``max_concurrent_transactions``.""" + if not items: + return [], [] + sem = asyncio.Semaphore(self._settings.max_concurrent_transactions) + + async def _bounded(item: ResolvedWrite) -> Contribution | BulkFailure: + async with sem: + return await self._insert_one_with_components(item) + + results = await asyncio.gather(*[_bounded(item) for item in items]) + succeeded: list[tuple[int, Contribution]] = [] + failed: list[BulkFailure] = [] + for item, outcome in zip(items, results, strict=True): + if isinstance(outcome, BulkFailure): + failed.append(outcome) + else: + succeeded.append((item.index, outcome)) + return succeeded, failed + + async def _insert_one_with_components(self, item: ResolvedWrite) -> Contribution | BulkFailure: + """Run a single contribution + its components inside a transaction. + + Uses ``session.with_transaction`` so transient txn errors (write conflicts, primary step- + downs) get pymongo's retry treatment. Any exception is converted to a ``BulkFailure`` so + the surrounding ``asyncio.gather`` sees a normal return value for every coroutine. + """ + contrib = item.contribution + try: + async with self._client.start_session() as session: + + async def _txn(s: AsyncClientSession) -> Contribution: + return await self._do_insert(contrib, s, item.version) + + return await session.with_transaction(_txn) + except AppError as exc: + return bulk_failure_from_exception(item.index, contrib.identifiers(), exc) + except Exception as exc: + logger.error( + "insert_contribution_failed", index=item.index, identifier=contrib.identifiers(), exc_info=True + ) + return bulk_failure_from_exception(item.index, contrib.identifiers(), exc) + + async def _do_insert(self, contrib: ContributionIn, session: AsyncClientSession, version: int) -> Contribution: + """Insert components then the contribution itself, all in the given session. + + Components are inserted sequentially because a session is single-threaded — sharing it + across concurrent awaits would corrupt the wire protocol. + """ + structures = await self._structures.insert_components(contrib.structures or [], session=session) + tables = await self._tables.insert_components(contrib.tables or [], session=session) + + doc = Contribution.from_input_model(contrib) + doc.version = version + doc.structures = cast(list[Link[Structure]] | None, structures or None) + doc.tables = cast(list[Link[Table]] | None, tables or None) + return await self._contributions.insert_contribution(doc, session=session) + + async def upsert_contributions(self, contributions: list[ContributionIn]) -> BulkWriteSummary[Contribution]: + """Upsert contributions by their identifying fields, reporting per-item outcomes. + + Components (structures, tables, attachments) must be managed via their respective + services. If any contribution in the batch carries components, the entire request is + rejected before any database writes occur. + + Each item is upserted atomically by ``ContributionIn.identifiers()`` via a single + ``findOneAndUpdate(..., upsert=True)`` so two requests targeting the same key cannot + race past the find branch — the unique index over those fields is the tiebreaker. + Concurrent upserts within a batch are bounded by ``settings.mongo.max_concurrent_transactions``. + A single item failing does not fail the batch: it is reported in ``failed`` while the others + still commit (mirroring ``insert_contributions``). + + Args: + contributions: contributions to upsert; must not include nested components + + Returns: + BulkWriteSummary[Contribution]: per-item outcome, sized to ``len(contributions)`` + + Raises: + ValidationError: if any contribution in the batch carries components + """ + if not contributions: + return BulkWriteSummary[Contribution](total=0, succeeded=[], failed=[]) + + indices_with_components = [i for i, c in enumerate(contributions) if c.has_components()] + if indices_with_components: + raise ValidationError( + "Components must be managed via their respective services, not via contribution upsert.", + contribution_indices=indices_with_components, + ) + + failures, plan = await self._split_contributions(contributions, is_upsert=True) + + sem = asyncio.Semaphore(self._settings.max_concurrent_transactions) + + async def _bounded_upsert(item: ResolvedWrite) -> Contribution | BulkFailure: + contrib = item.contribution + async with sem: + try: + return await self._contributions.upsert_contribution_by_identifiers( + contrib.identifiers(), contrib, item.version + ) + except Exception as exc: + logger.error( + "upsert_contribution_failed", index=item.index, identifier=contrib.identifiers(), exc_info=True + ) + return bulk_failure_from_exception(item.index, contrib.identifiers(), exc) + + results = await asyncio.gather(*[_bounded_upsert(item) for item in plan]) + succeeded = [r for r in results if not isinstance(r, BulkFailure)] + failed = failures + [r for r in results if isinstance(r, BulkFailure)] + return BulkWriteSummary[Contribution](total=len(contributions), succeeded=succeeded, failed=failed) + + async def delete_contributions(self, filter: ContributionFilter) -> BulkDeleteSummary: + """Delete a contribution and all of its child components + + Doesn't guarantee complete atomicity, but prevents orphaned children by deleting components first. + + Args: + filter (ContributionFilter): the Contribution-specific query to apply on top of the user scope + + + Returns: + BulkDeleteSummary: a summary of how many documents and child documents were deleted + """ + num_deleted_components = 0 + num_deleted_contributions = 0 + # Loop through cursor rather than materialize arbitrary number of Contributions + while True: + # Since we are deleting everything matching filter, we can continuously get the 1st page + page = await self._contributions.get_contributions( + pagination=CursorParams(cursor=None, limit=100), + filter=filter, + ) + # For each component type, gather ObjectIds then bulk delete them + # - components first so no children are left orphaned + for field, repo in self._children.items(): + ids = [link.ref.id for c in page.items for link in (getattr(c, field) or [])] + if ids: + deleted_components = await repo.delete_by_ids(ids) + num_deleted_components += deleted_components.num_deleted if deleted_components else 0 + + # Delete Contributions in this batch by ID + # need to make a new filter so we don't eagerly delete all contributions before their components are deleted + deleted_contribs = await self._contributions.delete_contributions( + ContributionFilter(id__in=[cast(PydanticObjectId, c.id) for c in page.items]) + ) + num_deleted_contributions += deleted_contribs.deleted_count if deleted_contribs else 0 + if not page.items: + break + return BulkDeleteSummary(num_deleted=num_deleted_contributions, num_children_deleted=num_deleted_components) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/healthcheck/router.py b/mpcontribs-api/src/mpcontribs_api/domains/healthcheck/router.py new file mode 100644 index 000000000..64bbb69fe --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/healthcheck/router.py @@ -0,0 +1,38 @@ +from botocore.exceptions import BotoCoreError, ClientError +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from mpcontribs_api.config import get_settings +from mpcontribs_api.dependencies import DbDep, S3Dep + +router = APIRouter(tags=["health"]) + +settings = get_settings() + + +class HealthStatus(BaseModel): + version: str + status: str + mongo: str + s3: str + + +@router.get("", response_model=HealthStatus, summary="Service health") +async def healthcheck(db: DbDep, s3_client: S3Dep) -> HealthStatus: + try: + await db.client.admin.command("ping") + except Exception: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"status": "unhealthy", "mongo": "unreachable"}, + ) from None + + try: + await s3_client.head_bucket(Bucket=settings.aws.health_bucket) + except ClientError, BotoCoreError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"status": "unhealthy", "s3": "unreachable"}, + ) from None + + return HealthStatus(version=settings.version, status="healthy", mongo="ok", s3="ok") diff --git a/mpcontribs-api/src/mpcontribs_api/domains/limits/models.py b/mpcontribs-api/src/mpcontribs_api/domains/limits/models.py new file mode 100644 index 000000000..a1fccbc5f --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/limits/models.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class Limits(BaseModel): + """Server-enforced request limits, advertised so callers can size their requests. + + These mirror the values enforced by the body-size middleware and the bulk write endpoints; + consumers (e.g. the mpcontribs client) should read them here rather than hardcoding. + """ + + max_request_bytes: int + bulk_write_limit: int + max_components_per_contribution: int + component_insert_chunk_size: int diff --git a/mpcontribs-api/src/mpcontribs_api/domains/limits/router.py b/mpcontribs-api/src/mpcontribs_api/domains/limits/router.py new file mode 100644 index 000000000..3d5393993 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/limits/router.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from mpcontribs_api.config import get_settings +from mpcontribs_api.domains.limits.models import Limits + +router = APIRouter() + + +@router.get("", response_model=Limits, summary="Server-enforced request limits") +async def get_limits() -> Limits: + """Return the request limits the server enforces. Public metadata; no auth required.""" + mongo = get_settings().mongo + return Limits( + max_request_bytes=mongo.max_request_bytes, + bulk_write_limit=mongo.bulk_write_limit, + max_components_per_contribution=mongo.max_components_per_contribution, + component_insert_chunk_size=mongo.component_insert_chunk_size, + ) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/dependencies.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/dependencies.py new file mode 100644 index 000000000..94535cc67 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/dependencies.py @@ -0,0 +1,15 @@ +from typing import Annotated + +from fastapi import Depends + +from mpcontribs_api.dependencies import UserDep +from mpcontribs_api.domains.projects.repository import ( + MongoDbProjectRepository, +) + + +def get_scoped_projects(user: UserDep) -> MongoDbProjectRepository: + return MongoDbProjectRepository(user) + + +ProjectDep = Annotated[MongoDbProjectRepository, Depends(get_scoped_projects)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/models.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/models.py new file mode 100644 index 000000000..7216c82ac --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/models.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl + +from mpcontribs_api import pagination +from mpcontribs_api.domains._shared.filters import BaseFilter +from mpcontribs_api.domains._shared.models import BaseDocumentWithInput, DocumentOut +from mpcontribs_api.domains._shared.types import PrefixedEmail, ShortStr + + +class Column(BaseModel): + path: str + min: float | None = None + max: float | None = None + unit: str | None = None + + @property + def segments(self) -> tuple[str, ...]: + return tuple(self.path.split(".")) + + +class Stats(BaseModel): + columns: int + contributions: int + tables: int + structures: int + attachments: int + size: float + + +class Reference(BaseModel): + # TODO: Labels have some restrictions, not sure exactly what yet + label: str + url: HttpUrl + + +class Project(BaseDocumentWithInput[ShortStr]): + """Document model of what is actually stored. + + Binds ``id`` to ``ShortStr`` (a meaningful string id, always supplied) via the generic base. + """ + + # Required + title: ShortStr + authors: str + description: str + owner: PrefixedEmail + unique_identifiers: bool + stats: Stats + + # Optional + references: list[Reference] = Field(default_factory=list) + long_title: str | None = None + other: dict[str, Any] = Field(default_factory=dict) + columns: list[Column] = Field(default_factory=list) + is_public: bool = False + is_approved: bool = False + license: Literal["CCA4", "CCPD"] | None = None + + # Empty method for now. Keeping for business logic later + @classmethod + def from_input_model(cls, data: ProjectIn) -> Project: + return cls(**data.model_dump()) + + @staticmethod + def decode_cursor(cursor: str) -> str: + """Decodes cursor and returns it as a str. + + Needs override over parent class since Project.id is a simple str + """ + return pagination.decode_cursor(cursor) + + class Settings: + name = "projects" + keep_nulls = False + + +class ProjectOut(DocumentOut[ShortStr]): + """Full response of all public-facing fields.""" + + model_config = ConfigDict(extra="ignore") + authors: str | None = None + description: str | None = None + title: ShortStr | None = None + owner: PrefixedEmail | None = None + other: dict[str, Any] | None = None + is_public: bool | None = None + is_approved: bool | None = None + long_title: str | None = None + unique_identifiers: bool | None = None + references: list[Reference] | None = None + stats: Stats | None = None + columns: list[Column] | None = None + license: Literal["CCA4", "CCPD"] | None = None + + @staticmethod + def default_fields() -> list[str]: + return ["id", "is_public", "title", "owner", "is_approved", "unique_identifiers"] + + +class ProjectFilter(BaseFilter): + """Filter fields allowed in requests.""" + + id: ShortStr | None = None + id__in: list[ShortStr] | None = None + id__neq: ShortStr | None = None + + title: ShortStr | None = None + title__in: list[ShortStr] | None = None + title__neq: ShortStr | None = None + title__ilike: str | None = None + + owner: PrefixedEmail | None = None + owner__in: list[PrefixedEmail] | None = None + owner__neq: PrefixedEmail | None = None + owner__ilike: str | None = None + + # fuzzy only + long_title__ilike: str | None = None + + is_public: bool | None = None + is_approved: bool | None = None + unique_identifiers: bool | None = None + + license: Literal["CCA4", "CCPD"] | None = None + license__in: list[Literal["CCA4", "CCPD"]] | None = None + + # sorting + order_by: list[str] | None = None + + class Constants(BaseFilter.Constants): + model = Project + + +# Keeping for business logic separation. May have specific implementation later +class ProjectIn(Project): + """Representation of user-supplied input.""" + + pass + + +class ProjectPatch(BaseModel): + """Nullable Project representation of user-supplied data for partial update (patch).""" + + title: ShortStr | None = None + authors: str | None = None + description: str | None = None + owner: PrefixedEmail | None = None + unique_identifiers: bool | None = None + references: list[Reference] = Field(default_factory=list) + long_title: str | None = None + other: dict[str, Any] = Field(default_factory=dict) + columns: list[Column] = Field(default_factory=list) + is_public: bool = False + is_approved: bool = False + license: Literal["CCA4", "CCPD"] | None = None diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py new file mode 100644 index 000000000..0e6f96a96 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py @@ -0,0 +1,135 @@ +from typing import Any + +from mpcontribs_api.authz import User +from mpcontribs_api.domains._shared.repository import MongoDbRepository +from mpcontribs_api.domains.projects.models import ( + Project, + ProjectFilter, + ProjectIn, + ProjectOut, + ProjectPatch, +) +from mpcontribs_api.exceptions import PermissionError +from mpcontribs_api.pagination import CursorParams + + +class MongoDbProjectRepository(MongoDbRepository[Project, ProjectIn, ProjectOut, ProjectFilter, ProjectPatch]): + """A repository layer for access to MongoDB. + + This is the layer that directly interacts with database operations. Shared CRUD logic lives on + :class:`MongoDbRepository`; the methods here are domain-named forwarders that give routers a + consistent vocabulary and concrete types, plus the operations whose shape is genuinely + project-specific (id-keyed upsert). + + Attributes: + _scope (dict[str, Any]): additional terms to inject into mongo queries to enforce user + authorization on resources + """ + + document_model = Project + out_model = ProjectOut + + def __init__(self, user: User) -> None: + super().__init__(user) + self._user = user + + @staticmethod + def _build_scope(user: User) -> dict[str, Any]: + """Provides scope based on current user's permitted groups and publicly released data.""" + if user.is_admin: + return {} + ors: list[dict[str, Any]] = [{"is_public": True, "is_approved": True}] + if not user.is_anonymous: + ors.append({"owner": user.username}) + if user.groups: + ors.append({"_id": {"$in": sorted(user.groups)}}) + return {"$or": ors} + + async def get_projects( + self, + filter: ProjectFilter, + pagination: CursorParams, + fields: frozenset[str] | None, + ): + """Query the Project collection, scoped to the current user. See ``get_many``.""" + return await self.get_many(pagination=pagination, filter=filter, fields=fields) + + async def get_project_by_id(self, id: str, fields: frozenset[str] | None): + """Find a single project by id, scoped to the current user. See ``get_by_id``.""" + return await self.get_by_id(id, fields) + + async def unique_identifiers_by_id(self, ids: list[str]) -> dict[str, bool]: + """Return ``{project_id: unique_identifiers}`` for the given project ids, scoped to the user. + + Used by the contribution write path to apply per-project version rules in one round-trip + instead of fetching each project separately. Projects the user cannot see (or that do not + exist) are simply absent from the result, so the caller can treat them as inaccessible. + + Args: + ids: project ids to look up + + Returns: + dict[str, bool]: mapping of project id to its ``unique_identifiers`` flag + """ + if not ids: + return {} + query: dict[str, Any] = {"_id": {"$in": ids}} + if self._scope: + query = {"$and": [self._scope, query]} + collection = self.document_model.get_pymongo_collection() + result: dict[str, bool] = {} + async for doc in collection.find(query, {"unique_identifiers": 1}): + result[doc["_id"]] = bool(doc.get("unique_identifiers")) + return result + + async def insert_project(self, project: ProjectIn) -> Project: + """Insert a new project, rejecting a duplicate id. See ``insert_one``.""" + return await self.insert_one(project) + + async def patch_project_by_id(self, id: str, update: ProjectPatch) -> Project: + """Partially update a project by id, scoped to the current user. See ``patch``.""" + return await self.patch(id, update) + + async def delete_project_by_id(self, id: str) -> None: + """Delete a project by id, scoped to the current user. See ``delete_by_id``.""" + await self.delete_by_id(id) + + async def upsert_project_by_id(self, id: str, data: ProjectIn) -> Project: + """Upsert a project by provided id, authorized to the current user. + + Update the document if the id exists, otherwise insert a new one under that id. + Authorization (the read scope is for visibility, not write access, so it is not + reused here): + + - **Existing project:** only its ``owner`` or an admin may overwrite it. The stored + ``owner`` is preserved — ownership cannot be reassigned through the request body. + - **New project:** ``owner`` is forced to the caller, ignoring any body value. + + Note: relies on the path param ``id`` for identity, not the body's id. + + Args: + id (str): the id of the project to upsert + data (ProjectIn): the data of the project to upsert + + Returns: + Project: the full document that either replaced an old one or was inserted + + Raises: + PermissionError: if a non-owner, non-admin caller targets an existing project + """ + # The route enforces authentication, so an anonymous caller should never reach here. + if self._user.username is None: + raise PermissionError(required_role="authenticated") + + existing = await self.document_model.find_one(self.document_model.id == id) + project = self.document_model.from_input_model(data) + project.id = id + if existing is not None: + if not (self._user.is_admin or existing.owner == self._user.username): + raise PermissionError(required_role="owner-or-admin") + # Ownership is immutable via upsert; keep the original owner. + project.owner = existing.owner + else: + # New project: the caller owns it, regardless of the submitted owner. + project.owner = self._user.username + return await project.save() diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py new file mode 100644 index 000000000..33dce7ebd --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py @@ -0,0 +1,120 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Response, status +from fastapi_filter import FilterDepends +from starlette.status import HTTP_204_NO_CONTENT + +from mpcontribs_api.dependencies import require_user +from mpcontribs_api.domains._shared.types import FieldSelector +from mpcontribs_api.domains.projects.dependencies import ProjectDep +from mpcontribs_api.domains.projects.models import ( + ProjectFilter, + ProjectIn, + ProjectOut, + ProjectPatch, +) +from mpcontribs_api.pagination import CursorParams + +router = APIRouter() + + +@router.get("") +async def get_projects( + repo: ProjectDep, + pagination: Annotated[CursorParams, Depends()], + filter: ProjectFilter = FilterDepends(ProjectFilter), + fields: FieldSelector = ProjectOut.default_fields(), +): + """Return paginated projects matching a filter. + + Args: + repo (ProjectDep): the project repo we depend on + pagination (CursorParams): arguments for cursor-based pagination + fields (str | None): optional fields to include in return. If None supplied, all fields are returned + + Returns: + list[ProjectSummary]: a list of smaller project payloads + """ + selected = ProjectOut.parse_fields(fields) + return await repo.get_projects(filter=filter, pagination=pagination, fields=selected) + + +@router.get("/{id}") +async def get_project_by_id( + id: str, + repo: ProjectDep, + fields: FieldSelector = ProjectOut.default_fields(), +): + """Gets a single project by its ID. + + Args: + id (str): the id of the project to retrieve + repo (ProjectDep): the project repo we depend on + fields (str | None): optional fields to include in return. If None supplied, all fields are returned + + Returns: + ProjectOut: the requested project, actual data returned is determined by the view the user requested + """ + selected = ProjectOut.parse_fields(fields) + return await repo.get_project_by_id(id=id, fields=selected) + + +@router.put("/{id}", response_model=ProjectOut, dependencies=[Depends(require_user)]) +async def upsert_project_by_id( + repo: ProjectDep, + id: str, + project: ProjectIn, +): + """Upsert a project by provided id. + + Upsert: Update document if id is found, otherwise insert new document using id. + Note: Relies on the path param 'id' for finding, rather than the body's id. + + Args: + repo (ProjectDep): the project repo we depend on + id (str): the id of the project to retrieve + project (ProjectIn): the data of the project to upsert + + Returns: + ProjectOut: the full document that either replaced an old one or was inserted + """ + return await repo.upsert_project_by_id(id=id, data=project) + + +@router.patch("/{id}", response_model=ProjectOut, dependencies=[Depends(require_user)]) +async def patch_project_by_id( + repo: ProjectDep, + id: str, + update: ProjectPatch, +): + """Partial update to project identified with 'id'. + + Note: overwrites fields with given values - arrays are not appended to. + + Args: + repo (ProjectDep): the project repo we depend on + id (str): the id of the project to update + update (ProjectPatch): the partial update to apply - unset fields are dropped + - Note: If fields are intentionally set to None, None is applied to the field. + + Returns: + ProjectOut: the full Project with updates applied + """ + return await repo.patch_project_by_id(id=id, update=update) + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_user)]) +async def delete_project_by_id( + repo: ProjectDep, + id: str, +): + """Deletes a project matching id. + + Args: + repo (ProjectDep): the project repo we depend on + id (str): the id of the project to be deleted + Returns: + Response: a response with the 204 response code (rather than FastAPIs default 200) + """ + await repo.delete_project_by_id(id=id) + return Response(status_code=HTTP_204_NO_CONTENT) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/structures/dependencies.py b/mpcontribs-api/src/mpcontribs_api/domains/structures/dependencies.py new file mode 100644 index 000000000..488e51314 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/structures/dependencies.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import Depends + +from mpcontribs_api.dependencies import UserDep +from mpcontribs_api.domains._shared.service import ComponentService +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.domains.structures.models import ( + Structure, + StructureFilter, + StructureIn, + StructureOut, + StructurePatch, +) +from mpcontribs_api.domains.structures.repository import MongoDbStructureRepository + +StructureService = ComponentService[Structure, StructureIn, StructureOut, StructureFilter, StructurePatch] + + +def get_structure_service(user: UserDep) -> StructureService: + return ComponentService( + MongoDbStructureRepository(user), + MongoDbContributionRepository(user), + ref_field="structures", + ) + + +StructureServiceDep = Annotated[StructureService, Depends(get_structure_service)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/structures/models.py b/mpcontribs-api/src/mpcontribs_api/domains/structures/models.py new file mode 100644 index 000000000..b4c8f6aa5 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/structures/models.py @@ -0,0 +1,115 @@ +from beanie import PydanticObjectId +from pydantic import BaseModel, ConfigDict +from pymatgen.core import Element + +from mpcontribs_api.domains._shared.filters import BaseFilter +from mpcontribs_api.domains._shared.models import Component, ComponentIn, DocumentOut +from mpcontribs_api.domains._shared.types import MD5Hash, PolarsFrame +from mpcontribs_api.projection import SparseFieldsModel + + +class SiteProperties(BaseModel): + magmom: float + + +class Species(BaseModel): + element: Element + occu: int + + +class Lattice(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + matrix: PolarsFrame + pbc: list[bool] + a: float + b: float + c: float + alpha: float + beta: float + gamma: float + volume: float + + +class Site(BaseModel): + species: list[Species] + abc: list[float] + properties: SiteProperties + label: str + xyz: list[float] + + +# Some things in Emmet-core that could assist in translating the pymatgen string to BaseModel +# In Mongo it is a single long string, but we could try to parse it into something typed +# It looks like it has some fields, then a table for n_atom_site_* with the subsequent lines being tab/space delimited +# rows +class Cif(BaseModel): + pass + + +class Structure(Component): + hash_fields = frozenset({"lattice", "sites", "charge"}) + lattice: Lattice + sites: list[Site] + charge: float | None + cif: str # Cif + + class Settings: + name = "structures" + + +class StructureIn(ComponentIn): + """User-supplied structure content.""" + + lattice: Lattice + sites: list[Site] + charge: float | None = None + cif: str + + +class StructureOut(DocumentOut[PydanticObjectId]): + model_config = ConfigDict(arbitrary_types_allowed=True) + name: str | None = None + md5: MD5Hash | None = None + lattice: Lattice | None = None + sites: list[Site] | None = None + charge: float | None = None + cif: str | None = None + + @staticmethod + def default_fields() -> list[str]: + return [ + "id", + "name", + "md5", + ] + + +class StructurePatch(SparseFieldsModel): + name: str | None = None + lattice: Lattice | None = None + sites: list[Site] | None = None + charge: float | None = None + cif: str | None = None + + +class StructureFilter(BaseFilter): + id: PydanticObjectId | None = None + id__in: list[PydanticObjectId] | None = None + id__neq: PydanticObjectId | None = None + + md5: MD5Hash | None = None + md5__in: list[MD5Hash] | None = None + md5__neq: MD5Hash | None = None + + name: str | None = None + name__in: list[str] | None = None + name__neq: str | None = None + name__ilike: str | None = None + + # sites + + # sorting + order_by: list[str] | None = None + + class Constants(BaseFilter.Constants): + model = Structure diff --git a/mpcontribs-api/src/mpcontribs_api/domains/structures/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/structures/repository.py new file mode 100644 index 000000000..ab7449785 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/structures/repository.py @@ -0,0 +1,15 @@ +from mpcontribs_api.domains._shared.components import MongoDbComponentsRepository +from mpcontribs_api.domains.structures.models import ( + Structure, + StructureFilter, + StructureIn, + StructureOut, + StructurePatch, +) + + +class MongoDbStructureRepository( + MongoDbComponentsRepository[Structure, StructureIn, StructureOut, StructureFilter, StructurePatch] +): + document_model = Structure + out_model = StructureOut diff --git a/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py b/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py new file mode 100644 index 000000000..803047648 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py @@ -0,0 +1,95 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from fastapi_filter import FilterDepends + +from mpcontribs_api.dependencies import S3Dep, require_user +from mpcontribs_api.domains._shared.bulk import BulkWriteSummary +from mpcontribs_api.domains._shared.models import ComponentDeleteResponse +from mpcontribs_api.domains._shared.types import ( + DownloadFormat, + FieldSelector, + ShortMimeFormat, + download_filename, +) +from mpcontribs_api.domains.structures.dependencies import StructureServiceDep +from mpcontribs_api.domains.structures.models import StructureFilter, StructureIn, StructureOut, StructurePatch +from mpcontribs_api.pagination import CursorParams + +router = APIRouter() + + +@router.get("") +async def get_structures( + service: StructureServiceDep, + pagination: Annotated[CursorParams, Depends()], + filter: StructureFilter = FilterDepends(StructureFilter), + fields: FieldSelector = StructureOut.default_fields(), +): + selected = StructureOut.parse_fields(fields) + return await service.get_many(filter=filter, fields=selected, pagination=pagination) + + +@router.get("/{pk}") +async def get_structure( + service: StructureServiceDep, + pk: str, + fields: FieldSelector = StructureOut.default_fields(), +): + selected = StructureOut.parse_fields(fields) + return await service.get_by_id(id=pk, fields=selected) + + +@router.get("/download/{short_mime}") +async def download_structure( + service: StructureServiceDep, + format: DownloadFormat, + s3: S3Dep, + short_mime: ShortMimeFormat = ShortMimeFormat.GZ, + ignore_cache: bool = False, + filter: StructureFilter = FilterDepends(StructureFilter), + fields: FieldSelector = StructureOut.default_fields(), +) -> StreamingResponse: + selected = StructureOut.parse_fields(fields) + body = await service.download( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=selected, + s3=s3, + ) + filename = download_filename("structures", format, short_mime) + return StreamingResponse( + body, + media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("", response_model=BulkWriteSummary[StructureOut], dependencies=[Depends(require_user)]) +async def insert_structures( + service: StructureServiceDep, + structures: list[StructureIn], +): + return await service.insert(components=structures) + + +@router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_structures(service: StructureServiceDep, filter: StructureFilter = FilterDepends(StructureFilter)): + return await service.delete(filter=filter) + + +@router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_structure_by_id(service: StructureServiceDep, id: str): + return await service.delete_by_id(id=id) + + +@router.patch("/{id}", dependencies=[Depends(require_user)]) +async def patch_structure_by_id( + service: StructureServiceDep, + id: str, + update: StructurePatch, +): + return await service.patch_by_id(id=id, update=update) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/tables/dependencies.py b/mpcontribs-api/src/mpcontribs_api/domains/tables/dependencies.py new file mode 100644 index 000000000..323a4443d --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/tables/dependencies.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import Depends + +from mpcontribs_api.dependencies import UserDep +from mpcontribs_api.domains._shared.service import ComponentService +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.domains.tables.models import ( + Table, + TableFilter, + TableIn, + TableOut, + TablePatch, +) +from mpcontribs_api.domains.tables.repository import MongoDbTableRepository + +TableService = ComponentService[Table, TableIn, TableOut, TableFilter, TablePatch] + + +def get_table_service(user: UserDep) -> TableService: + return ComponentService( + MongoDbTableRepository(user), + MongoDbContributionRepository(user), + ref_field="tables", + ) + + +TableServiceDep = Annotated[TableService, Depends(get_table_service)] diff --git a/mpcontribs-api/src/mpcontribs_api/domains/tables/models.py b/mpcontribs-api/src/mpcontribs_api/domains/tables/models.py new file mode 100644 index 000000000..44cb0368e --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/tables/models.py @@ -0,0 +1,193 @@ +from collections.abc import Mapping +from typing import Any, Self + +import polars as pl +from beanie import PydanticObjectId +from pydantic import ( + BaseModel, + ConfigDict, + field_serializer, + model_validator, +) + +from mpcontribs_api.domains._shared.filters import BaseFilter +from mpcontribs_api.domains._shared.models import Component, ComponentIn, DocumentOut +from mpcontribs_api.domains._shared.types import MD5Hash, PolarsFrame +from mpcontribs_api.projection import SparseFieldsModel + + +class Labels(BaseModel): + index: str + value: str + variable: str + + +class Attributes(BaseModel): + title: str + labels: Labels + + +def frame_to_storage(frame: pl.DataFrame) -> tuple[list[str], list[str], list[list[str]]]: + """Split a DataFrame into (index, columns, data) for storage. + + The first column is the index; the rest are the data columns. Every value is stringified. + """ + if not frame.columns: + return [], [], [] + index_col, *data_cols = frame.columns + index = [str(v) for v in frame[index_col].to_list()] + data = [[str(v) for v in row] for row in frame.select(data_cols).iter_rows()] + return index, list(data_cols), data + + +def storage_to_frame(index_label: str, index: list[str], columns: list[str], data: list[list[str]]) -> pl.DataFrame: + """Rebuild the DataFrame from stored (index, columns, data), all columns typed as strings.""" + schema = {name: pl.Utf8 for name in [index_label, *columns]} + rows = [[idx, *row] for idx, row in zip(index, data, strict=True)] + return pl.DataFrame(rows, schema=schema, orient="row") + + +def _index_label(attrs: Any) -> str: + """The DataFrame's index-column name comes from ``attrs.labels.index`` (dict or model).""" + if attrs is None: + return "index" + if isinstance(attrs, Mapping): + return attrs.get("labels", {}).get("index", "index") + return getattr(getattr(attrs, "labels", None), "index", "index") + + +class Table(Component): + """Stored table document — matches the existing MongoDB shape (index/columns/data as strings).""" + + hash_fields = frozenset({"attrs", "index", "columns", "data"}) + + attrs: Attributes + index: list[str] + columns: list[str] + data: list[list[str]] + total_data_rows: int + + class Settings: + name = "tables" + + @classmethod + def from_input(cls, input: TableIn) -> Self: # pyright: ignore[reportIncompatibleMethodOverride] + # The input frame's first column is the index; the rest are the data columns. total_data_rows + # is derived from the data, so it is always consistent by construction. + index, columns, data = frame_to_storage(input.data) + return cls( + _id=PydanticObjectId(), + name=input.name, + attrs=input.attrs, + index=index, + columns=columns, + data=data, + total_data_rows=len(data), + ) + + +class TableIn(ComponentIn): + """User-supplied table content as a DataFrame (first column = index). + + ``_id`` and ``md5`` are server-assigned, so absent here. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + attrs: Attributes + data: PolarsFrame + + +class TableFilter(BaseFilter): + id: PydanticObjectId | None = None + id__in: list[PydanticObjectId] | None = None + id__neq: PydanticObjectId | None = None + + md5: MD5Hash | None = None + md5__in: list[MD5Hash] | None = None + md5__neq: MD5Hash | None = None + + name: str | None = None + name__in: list[str] | None = None + name__neq: str | None = None + name__ilike: str | None = None + + # Columns + # Attrs + + # sorting + order_by: list[str] | None = None + + class Constants(BaseFilter.Constants): + model = Table + + @field_serializer("id", "id__in", "id__neq") + def id_to_str(self, v: PydanticObjectId | list[PydanticObjectId] | None) -> str | list[str] | None: + if v is None: + return None + if isinstance(v, list): + return sorted(str(o) for o in v) + return str(v) + + +class TableOut(DocumentOut[PydanticObjectId]): + # extra="allow" so that when ``data`` is requested the full document (including the stored + # ``index``/``columns``) is fetched — Beanie derives the Mongo projection from a plain model's + # fields, and the frame can only be rebuilt from all three. ``_assemble_frame`` drops the raw + # storage keys so they never surface on the response. + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") + name: str | None = None + md5: MD5Hash | None = None + attrs: Attributes | None = None + total_data_rows: int | None = None + data: PolarsFrame | None = None + + @model_validator(mode="before") + @classmethod + def _assemble_frame(cls, value: Any) -> Any: + """Rebuild ``data`` as a DataFrame from the stored ``index``/``columns``/``data`` triple. + + Accepts either a Mongo dict (read path) or a ``Table``-like object (download path, + ``from_attributes``). When the storage triple is absent (e.g. a light projection without + ``data``, or a TableOut built directly with a frame) the input passes through unchanged. + """ + getter = value.get if isinstance(value, Mapping) else lambda key: getattr(value, key, None) + index, columns, raw = getter("index"), getter("columns"), getter("data") + if index is None or columns is None or not isinstance(raw, list): + return value + + attrs = getter("attrs") + index_label = _index_label(attrs) + normalized: dict[str, Any] = { + "_id": getter("id") if isinstance(value, Mapping) and "id" in value else getter("_id") or getter("id"), + "name": getter("name"), + "md5": getter("md5"), + "attrs": attrs, + "total_data_rows": getter("total_data_rows"), + "data": storage_to_frame(index_label, index, columns, raw), + } + return {key: val for key, val in normalized.items() if val is not None} + + @staticmethod + def default_fields() -> list[str]: + # Light default; the tabular payload (data) is fetched via ?_fields=. + return [ + "id", + "name", + "md5", + "attrs", + "total_data_rows", + ] + + @classmethod + def projection(cls, fields): + # Light reads use the normal partial-projection (no data/index/columns fetched). When the + # frame is requested, fall back to the full model so index+columns+data come back together + # and ``_assemble_frame`` can rebuild it. + if fields is not None and "data" not in fields: + return super().projection(fields) + return cls + + +class TablePatch(SparseFieldsModel): + name: str | None = None + attrs: Attributes | None = None diff --git a/mpcontribs-api/src/mpcontribs_api/domains/tables/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/tables/repository.py new file mode 100644 index 000000000..a6bda1791 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/tables/repository.py @@ -0,0 +1,13 @@ +from mpcontribs_api.domains._shared.components import MongoDbComponentsRepository +from mpcontribs_api.domains.tables.models import ( + Table, + TableFilter, + TableIn, + TableOut, + TablePatch, +) + + +class MongoDbTableRepository(MongoDbComponentsRepository[Table, TableIn, TableOut, TableFilter, TablePatch]): + document_model = Table + out_model = TableOut diff --git a/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py b/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py new file mode 100644 index 000000000..703f0c6fa --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py @@ -0,0 +1,95 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from fastapi_filter import FilterDepends + +from mpcontribs_api.dependencies import S3Dep, require_user +from mpcontribs_api.domains._shared.bulk import BulkWriteSummary +from mpcontribs_api.domains._shared.models import ComponentDeleteResponse +from mpcontribs_api.domains._shared.types import ( + DownloadFormat, + FieldSelector, + ShortMimeFormat, + download_filename, +) +from mpcontribs_api.domains.tables.dependencies import TableServiceDep +from mpcontribs_api.domains.tables.models import Table, TableFilter, TableIn, TableOut, TablePatch +from mpcontribs_api.pagination import CursorParams + +router = APIRouter() + + +@router.get("") +async def get_tables( + service: TableServiceDep, + pagination: Annotated[CursorParams, Depends()], + filter: TableFilter = FilterDepends(TableFilter), + fields: FieldSelector = TableOut.default_fields(), +): + selected = TableOut.parse_fields(fields) + return await service.get_many(filter=filter, fields=selected, pagination=pagination) + + +@router.get("/{pk}") +async def get_table( + service: TableServiceDep, + pk: str, + fields: FieldSelector = TableOut.default_fields(), +): + selected = TableOut.parse_fields(fields) + return await service.get_by_id(id=pk, fields=selected) + + +@router.get("/download/{short_mime}") +async def download_table( + service: TableServiceDep, + s3: S3Dep, + format: DownloadFormat, + short_mime: ShortMimeFormat = ShortMimeFormat.GZ, + ignore_cache: bool = False, + filter: TableFilter = FilterDepends(TableFilter), + fields: FieldSelector = TableOut.default_fields(), +) -> StreamingResponse: + selected = TableOut.parse_fields(fields) + body = await service.download( + format=format, + short_mime=short_mime, + ignore_cache=ignore_cache, + filter=filter, + fields=selected, + s3=s3, + ) + filename = download_filename("tables", format, short_mime) + return StreamingResponse( + body, + media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("", response_model=BulkWriteSummary[Table], dependencies=[Depends(require_user)]) +async def insert_tables( + service: TableServiceDep, + tables: list[TableIn], +): + return await service.insert(components=tables) + + +@router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_tables(service: TableServiceDep, filter: TableFilter = FilterDepends(TableFilter)): + return await service.delete(filter=filter) + + +@router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) +async def delete_table_by_id(service: TableServiceDep, id: str): + return await service.delete_by_id(id=id) + + +@router.patch("/{id}", dependencies=[Depends(require_user)]) +async def patch_table_by_id( + service: TableServiceDep, + id: str, + update: TablePatch, +): + return await service.patch_by_id(id=id, update=update) diff --git a/mpcontribs-api/src/mpcontribs_api/exceptions.py b/mpcontribs-api/src/mpcontribs_api/exceptions.py new file mode 100644 index 000000000..d84e74817 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/exceptions.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import Any + +import structlog +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode +from starlette.exceptions import HTTPException as StarletteHTTPException + +logger = structlog.get_logger(__name__) + + +def _record_on_span(exc: Exception) -> None: + """Attach a server-side exception to the active OTel span so APM error views show the stack. + + No-op when there is no recording span (telemetry disabled or no active span), since the + sentinel span returned in that case reports ``is_recording() == False``. + """ + span = trace.get_current_span() + if span.is_recording(): + span.record_exception(exc) + span.set_status(Status(StatusCode.ERROR, str(exc))) + + +class AppError(Exception): + """Base for all application-level domain errors. + + Carries enough structured context for a handler to build an HTTP + response without the raising code knowing anything about HTTP. + """ + + # Subclasses override these. Kept as class attrs so a handler can read + # them off the type without instantiating special-casing. + status_code: int = 500 + error_code: str = "internal_error" # stable, machine-readable + # Optional stdlib log level override. None => the handler derives it from status_code + # (ERROR for 5xx, INFO for 4xx). Set on a subclass to promote a notable client error, + # e.g. WARNING for auth failures so they're alertable without logging every 404. + log_level: int | None = None + + def __init__(self, message: str | None = None, **context): + self.message = message or self.__class__.__name__ + self.context = context # extra fields for logging / response + super().__init__(self.message) + + +class NotFoundError(AppError): + status_code = 404 + error_code = "not_found" + log_level = logging.INFO + + +class ConflictError(AppError): + status_code = 409 + error_code = "conflict" + log_level = logging.INFO + + +class ValidationError(AppError): + status_code = 422 + error_code = "validation_error" + log_level = logging.INFO + + +class PayloadTooLargeError(AppError): + status_code = 413 + error_code = "payload_too_large" + log_level = logging.INFO # expected client error: caller sent an oversized body + + +class PermissionError(AppError): + status_code = 403 + error_code = "permission_denied" + log_level = logging.WARNING # security-relevant: alertable, but not a server fault + + +class AuthenticationError(AppError): + status_code = 401 + error_code = "authentication_error" + log_level = logging.WARNING # security-relevant: alertable, but not a server fault + + +def error_body(error_code: str, message: str, **public_context) -> dict: + body: dict[str, Any] = {"error": {"code": error_code, "message": message}} + if public_context: + body["error"]["detail"] = public_context + return body + + +def _sanitize_validation_errors(errors: Sequence[Any]) -> list[dict[str, Any]]: + """Drop the echoed input value and pydantic doc URL from each error.""" + return [{key: value for key, value in error.items() if key not in ("input", "url")} for error in errors] + + +def register_exception_handlers(app: FastAPI) -> None: + """Register all exception handlers to the app. + + Args: + app (FastAPI): the fastapi app to register the exception handlers to + """ + + @app.exception_handler(AppError) + async def _handle_app_error(request: Request, exc: AppError) -> JSONResponse: + is_server_fault = exc.status_code >= 500 + level = exc.log_level if exc.log_level is not None else (logging.ERROR if is_server_fault else logging.INFO) + if is_server_fault: + # Only genuine server faults carry a traceback and mark the span; client errors + # (incl. opt-in WARNING ones like auth failures) are expected and need neither. + _record_on_span(exc) + logger.log(level, exc.error_code, status_code=exc.status_code, exc_info=exc, **exc.context) + else: + logger.log(level, exc.error_code, status_code=exc.status_code, **exc.context) + return JSONResponse( + status_code=exc.status_code, + content=error_body( + exc.error_code, + exc.message, + # NOTE: not leaking context to client yet + # - need to make client-safe (no leakage of secrets) on a per-exception type basis + # **exc.context + ), + ) + + # Catch-all for anything that isn't an AppError - bugs, unexpected failures. + @app.exception_handler(Exception) + async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse: + _record_on_span(exc) + logger.exception("unhandled_exception") # full traceback + return JSONResponse( + status_code=500, + content=error_body("internal_error", "An unexpected error occurred."), + ) + + # Unify validation errors from pydantic with our exception format + @app.exception_handler(RequestValidationError) + async def _handle_validation(_request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse( + status_code=422, + content=error_body( + "validation_error", + "Request validation failed", + errors=jsonable_encoder(_sanitize_validation_errors(exc.errors())), + ), + ) + + # Unify http exceptions from starlette with our exception format + @app.exception_handler(StarletteHTTPException) + async def _handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse: + if exc.status_code >= 500: + _record_on_span(exc) + logger.error("http_error", status_code=exc.status_code, exc_info=exc) + return JSONResponse( + status_code=exc.status_code, + content=error_body("http_error", str(exc.detail)), + ) diff --git a/mpcontribs-api/src/mpcontribs_api/logging.py b/mpcontribs-api/src/mpcontribs_api/logging.py new file mode 100644 index 000000000..6cf2bcb4c --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/logging.py @@ -0,0 +1,151 @@ +import logging +import sys + +import structlog +from opentelemetry import trace +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.resources import Resource + +from mpcontribs_api.config import Settings + +# Held across calls so repeated configure_logging() (tests, reloads) reuses one provider rather than +# re-registering. Shared with middleware.py, which builds the tracer/meter providers from the same +# resource so all three signals carry identical service identity. +_logger_provider: LoggerProvider | None = None + + +def build_resource(settings: Settings) -> Resource: + """OTEL resource describing this service. Single source of truth for traces, metrics, and logs.""" + return Resource.create( + { + "service.name": settings.otel.service_name, + "service.version": settings.version, + "deployment.environment": settings.environment, + } + ) + + +def add_otel_trace_context(_, __, event_dict): + span = trace.get_current_span() + ctx = span.get_span_context() + # If context is not the sentinel span (ie. we have an active span) + if ctx.is_valid: + # Convert to OTel-expected ids + # ctx ids are formatted as 128-bit (trace_id) and 64-bit (span_id) long numbers. + # OTel expects them as 0-padded hex numbers + event_dict["trace_id"] = format(ctx.trace_id, "032x") # 32 digits + event_dict["span_id"] = format(ctx.span_id, "016x") # 16 digits + return event_dict + + +def _build_otlp_log_handler(settings: Settings, log_level: int, shared_processors: list) -> LoggingHandler | None: + """Build a stdlib handler that ships records to the OTLP logs pipeline (the Datadog Agent's OTLP + receiver), or ``None`` when telemetry is disabled. + + The record body is the structlog event rendered to JSON; the SDK stamps each record with the + active span's trace/span ids, so logs correlate with traces. + """ + global _logger_provider + if not settings.otel.enabled: + return None + + if _logger_provider is None: + _logger_provider = LoggerProvider(resource=build_resource(settings)) + _logger_provider.add_log_record_processor( + BatchLogRecordProcessor( + OTLPLogExporter(endpoint=settings.otel.otlp_endpoint, insecure=settings.otel.insecure) + ) + ) + set_logger_provider(_logger_provider) + + handler = LoggingHandler(level=log_level, logger_provider=_logger_provider) + # ProcessorFormatter renders the event dict to a JSON string, which LoggingHandler uses as the + # log body (it calls self.format() when a formatter is set). Always JSON regardless of + # environment - the OTLP body should be structured even when stdout is the dev console renderer. + handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.processors.dict_tracebacks, + structlog.processors.EventRenamer("message"), + structlog.processors.JSONRenderer(), + ], + ) + ) + return handler + + +def configure_logging(settings: Settings) -> None: + is_prod = settings.environment == "prod" + log_level = logging.INFO if is_prod else logging.DEBUG + + # Run on both structlog events and foreign (stdlib) records. + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + add_otel_trace_context, + ] + + # Exception handling is paired with the renderer, not shared. + if is_prod: + render_chain = [ + structlog.processors.dict_tracebacks, + structlog.processors.EventRenamer("message"), + structlog.processors.JSONRenderer(), + ] + else: + render_chain = [structlog.dev.ConsoleRenderer()] + + # Handles internal logs emitted by structlog logger + structlog.configure( + processors=shared_processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.make_filtering_bound_logger(log_level), + cache_logger_on_first_use=True, + ) + + # Handles logs emitted by stdlib logging in external libraries + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + *render_chain, + ], + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + # stdout stays for kubectl logs/ebugging; the OTLP handler (when enabled) ships the same + # records to the collector, so Datadog ingests via OTLP rather than tailing stdout. + handlers: list[logging.Handler] = [handler] + otlp_handler = _build_otlp_log_handler(settings, log_level, shared_processors) + if otlp_handler is not None: + handlers.append(otlp_handler) + + root = logging.getLogger() + root.handlers = handlers + root.setLevel(log_level) + + # Let uvicorn's loggers flow through the root handler instead of their own. + for name in ("uvicorn", "uvicorn.error"): + lg = logging.getLogger(name) + lg.handlers = [] + lg.propagate = True + + # Silence uvicorn's default access log: RequestContextMiddleware emits our own structured access + # event with status/size/duration and Datadog standard attributes. disabled=True suppresses it + # regardless of uvicorn's --access-log flag, so the two never double-log. + access = logging.getLogger("uvicorn.access") + access.handlers = [] + access.propagate = False + access.disabled = True + + +def get_logger(name: str | None = None): + return structlog.get_logger(name) diff --git a/mpcontribs-api/src/mpcontribs_api/middleware.py b/mpcontribs-api/src/mpcontribs_api/middleware.py new file mode 100644 index 000000000..8a463a2a2 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/middleware.py @@ -0,0 +1,274 @@ +import os +import time +import uuid +from collections.abc import Iterable +from typing import TYPE_CHECKING + +import structlog +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.pymongo import PymongoInstrumentor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from mpcontribs_api.config import Settings +from mpcontribs_api.exceptions import PayloadTooLargeError, error_body +from mpcontribs_api.logging import build_resource, get_logger + +if TYPE_CHECKING: + from fastapi import FastAPI + +# Guards repeated configure_tracing() (tests, reloads) from re-registering providers or +# double-instrumenting pymongo. +_configured = False + +# Dedicated access logger. Emits one structured event per request (see RequestContextMiddleware); +# flows through the same root handlers as everything else (stdout + OTLP when enabled). +_access_logger = get_logger("access") + +# Process identity, matching the old gunicorn ``{group}/{process}`` prefix and ``%(p)s`` pid. The +# SUPERVISOR_* vars are absent outside supervisord (dev, tests), so these render null there. +_PROCESS = { + "name": os.getenv("SUPERVISOR_PROCESS_NAME"), + "group": os.getenv("SUPERVISOR_GROUP_NAME"), + "id": os.getpid(), +} + +# Optional request headers folded into the access log's ``http.*`` block, only when sent. +_ACCESS_LOG_HEADERS: dict[bytes, str] = { + b"accept": "accept", + b"accept-language": "accept_language", + b"accept-encoding": "accept_encoding", + b"content-type": "content_type", + b"content-length": "content_length", +} + + +class RequestContextMiddleware: + """Bind per-request structlog context and emit one structured access log per HTTP request. + + The two concerns live together because both need the request headers parsed once, and this is + the outermost app middleware, so it observes the final status code, total response bytes, and + full request duration. The access event uses Datadog standard attribute names (``http.*``, + ``network.*``, ``duration`` in nanoseconds) so the existing Datadog log pipeline (URL parser, + User-Agent parser, status-category, date remapper) regenerates the same enriched surface the old + gunicorn access logs had. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + raw_headers: Iterable[tuple[bytes, bytes]] = scope["headers"] + headers = {name: value for name, value in raw_headers} + + raw_request_id = headers.get(b"x-request-id") + request_id = raw_request_id.decode() if raw_request_id else str(uuid.uuid4()) + + context: dict[str, str] = { + "request_id": request_id, + "method": scope["method"], + "path": scope["path"], + } + # Kong-resolved consumer identity for log correlation + raw_consumer_id = headers.get(b"x-consumer-id") + if raw_consumer_id is not None: + context["consumer_id"] = raw_consumer_id.decode() + + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars(**context) + + # Capture the final status and body size by wrapping send; the app may call send many times + # (streaming/chunked), so body bytes accumulate across http.response.body messages. + status_code = 0 + bytes_written = 0 + + async def send_wrapper(message: Message) -> None: + nonlocal status_code, bytes_written + if message["type"] == "http.response.start": + status_code = message["status"] + elif message["type"] == "http.response.body": + bytes_written += len(message.get("body", b"")) + await send(message) + + start = time.perf_counter() + try: + await self.app(scope, receive, send_wrapper) + except Exception: + # An exception escaped the app's own handlers (e.g. raised in outer middleware or after + # the response had started, so the catch-all never ran). Record it as a 500 instead of + # the sentinel 0 so status-based alerting still fires, then re-raise unchanged. + if status_code == 0: + status_code = 500 + raise + finally: + self._emit_access_log(scope, headers, status_code, bytes_written, start) + + @staticmethod + def _emit_access_log( + scope: Scope, + headers: dict[bytes, bytes], + status_code: int, + bytes_written: int, + start: float, + ) -> None: + duration_ns = int((time.perf_counter() - start) * 1e9) + query_string = scope.get("query_string", b"").decode("latin-1") + path = scope["path"] + url = f"{path}?{query_string}" if query_string else path + # True client IP behind Kong: first X-Forwarded-For hop if present, else the direct peer (which is Kong itself) + forwarded_for = headers.get(b"x-forwarded-for", b"").decode("latin-1") + client = scope.get("client") + client_ip = forwarded_for.split(",")[0].strip() if forwarded_for else (client[0] if client else "") + + http: dict[str, object] = { + "method": scope["method"], + "status_code": status_code, + "url": url, + "referer": headers.get(b"referer", b"-").decode("latin-1"), + "useragent": headers.get(b"user-agent", b"").decode("latin-1"), + "version": scope.get("http_version", "1.1"), + } + for header_name, http_key in _ACCESS_LOG_HEADERS.items(): + value = headers.get(header_name) + if value is not None: + http[http_key] = value.decode("latin-1") + + _access_logger.info( + "http.access", + http=http, + network={"bytes_written": bytes_written, "client": {"ip": client_ip}}, + duration=duration_ns, # Datadog standard `duration` is nanoseconds + response_time=duration_ns // 1000, # microseconds, matches the old gunicorn %(D)s + process=_PROCESS, + ) + + +class _BodyTooLarge(BaseException): + """Internal signal that the streamed body exceeded the limit. + + Derives from ``BaseException`` (not ``Exception``) on purpose: FastAPI's request-body parser + wraps body reads in ``except Exception`` and would otherwise convert our error into a generic + ``400``. A ``BaseException`` slips past that (and past Starlette's exception middleware) so it + propagates back to ``BodySizeLimitMiddleware``, which turns it into the uniform ``413``. + """ + + +class BodySizeLimitMiddleware: + """Reject request bodies larger than ``max_bytes`` so one caller can't OOM the worker. + + Two enforcement points, because a client can lie about (or omit) ``Content-Length``: + + - **Declared size:** if the ``Content-Length`` header exceeds the limit, respond ``413`` + immediately, before the body is read. This is the common case (the mpcontribs client and + any ``requests``-based caller set ``Content-Length``) and avoids buffering the upload at all. + - **Actual size:** otherwise wrap ``receive`` and accumulate the bytes actually delivered + (chunked transfers, or a lying header); once the running total exceeds the limit, abort. The + abort is signalled by a ``BaseException`` raised from the wrapped ``receive`` (see + :class:`_BodyTooLarge` for why) and caught here, so the response is the same uniform ``413`` + as the declared-size path, and the body is never fully buffered. + + Registered inside ``RequestContextMiddleware`` so rejected requests are still access-logged. + """ + + def __init__(self, app: ASGIApp, max_bytes: int) -> None: + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = dict(scope["headers"]) + declared = self._declared_length(headers.get(b"content-length")) + if declared is not None and declared > self.max_bytes: + await self._reject(scope, receive, send) + return + + received = 0 + + async def limited_receive() -> Message: + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_bytes: + raise _BodyTooLarge + return message + + try: + await self.app(scope, limited_receive, send) + except _BodyTooLarge: + # Overflow detected mid-body-read: the app hasn't started responding yet, so we own the + # response and emit the 413 ourselves. + await self._reject(scope, receive, send) + + @staticmethod + def _declared_length(raw: bytes | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError: + return None + + def _message(self) -> str: + return f"Request body exceeds the maximum allowed size of {self.max_bytes} bytes." + + async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None: + response = JSONResponse( + status_code=PayloadTooLargeError.status_code, + content=error_body(PayloadTooLargeError.error_code, self._message()), + ) + await response(scope, receive, send) + + +def configure_tracing(settings: Settings) -> None: + """Register the global tracer and meter providers, exporting via OTLP/gRPC to the collector. + + Covers the request-scoped signals: ``instrument_app`` (below) emits server spans and + ``http.server`` metrics through these providers, and pymongo spans cover the DB layer. No-op when + telemetry is disabled or already configured. + """ + global _configured + if _configured or not settings.otel.enabled: + return + + resource = build_resource(settings) + endpoint = settings.otel.otlp_endpoint + insecure = settings.otel.insecure + + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=insecure))) + trace.set_tracer_provider(tracer_provider) + + metric_reader = PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=endpoint, insecure=insecure), + export_interval_millis=settings.otel.metric_export_interval_ms, + ) + metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader])) + + # Registers a pymongo command listener, so DB spans cover the async client used by Beanie too. + PymongoInstrumentor().instrument() + + _configured = True + + +def instrument_app(app: FastAPI, settings: Settings) -> None: + """Instrument the FastAPI app for server-side request spans/metrics. No-op when disabled.""" + if not settings.otel.enabled: + return + # Imported lazily: pulls in the ASGI instrumentation, only needed when enabled. + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + FastAPIInstrumentor.instrument_app(app) diff --git a/mpcontribs-api/src/mpcontribs_api/pagination.py b/mpcontribs-api/src/mpcontribs_api/pagination.py new file mode 100644 index 000000000..cf4d11298 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/pagination.py @@ -0,0 +1,44 @@ +import base64 + +from pydantic import BaseModel, Field + + +class CursorParams(BaseModel): + """Models parameters used in cursor-based pagination.""" + + # None == First page + cursor: str | None = None + # Per-page limit + limit: int = Field(default=20, ge=1, le=100) + + +class Page[T](BaseModel): + """Model to be returned for a single page of cursor-based paginated results. + + Attributes: + items (list[T]): the items returned for the given page + next_cursor (str): the base64-encoded value of the first id on the next page. If None, then no more pages are + available + """ + + items: list[T] + # None == last page + next_cursor: str | None = None + + +def encode_cursor(last_id: str) -> str: + """Base64 encodes a cursor for pagination + + Uses base64 instead of raw str to prevent manual tampering from users + """ + return base64.urlsafe_b64encode(last_id.encode()).decode() + + +def decode_cursor(cursor: str) -> str: + """Base64 decodes a cursor for pagination""" + # Re-add any base64 padding ('=') that may have been stripped in transit (e.g. via URLs). + padded = cursor + "=" * (-len(cursor) % 4) + try: + return base64.urlsafe_b64decode(padded.encode()).decode() + except ValueError, UnicodeDecodeError: + raise ValueError("malformed cursor") from None diff --git a/mpcontribs-api/src/mpcontribs_api/projection.py b/mpcontribs-api/src/mpcontribs_api/projection.py new file mode 100644 index 000000000..0346a1f21 --- /dev/null +++ b/mpcontribs-api/src/mpcontribs_api/projection.py @@ -0,0 +1,247 @@ +"""Handles the projection of '_fields' from query params. + +This includes arbitrarily specifying nested structures with '.' +Ie. data.band_gap.something will be properly retrieved and populated into the response model that subclasses + SparseFieldsModel +""" + +from __future__ import annotations + +from collections.abc import Iterator +from functools import lru_cache +from typing import ( + Any, + ClassVar, + Literal, + NamedTuple, + Self, + TypeVar, + cast, + get_args, + get_origin, +) + +from pydantic import BaseModel, create_model +from pydantic.fields import FieldInfo + +from mpcontribs_api.exceptions import ValidationError + +ModelT = TypeVar("ModelT", bound=BaseModel) + +# How a model field's annotation is categorised for projection. +FieldKind = Literal["model", "dict", "list", "scalar"] +# A path step may also land on a dict key ("opaque") or a name absent from a model ("unknown"). +StepKind = Literal["model", "dict", "list", "scalar", "opaque", "unknown"] + + +class PathStep(NamedTuple): + """One resolved segment of a dotted field path.""" + + segment: str + field: FieldInfo | None + kind: StepKind + is_last: bool + + +def _unwrap_optional(annotation: object) -> object: + """Strip a single ``T | None`` wrapper, returning the inner annotation.""" + arguments = get_args(annotation) + if type(None) in arguments: + non_none = [arg for arg in arguments if arg is not type(None)] + if len(non_none) == 1: + return non_none[0] + return annotation + + +def _classify(annotation: object) -> tuple[FieldKind, type[BaseModel] | None]: + """Categorise an annotation as model / dict / list / scalar.""" + annotation = _unwrap_optional(annotation) + origin = get_origin(annotation) + if annotation is Any or annotation is dict or origin is dict: + return "dict", None + if origin in (list, set, tuple, frozenset): + return "list", None + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return "model", annotation + return "scalar", None + + +def _walk_path(model: type[BaseModel], path: str) -> Iterator[PathStep]: + """Yield one step per segment, descending into models and going opaque past a dict.""" + current_model: type[BaseModel] | None = model + segments = path.split(".") + for index, segment in enumerate(segments): + is_last = index == len(segments) - 1 + if current_model is None: # inside a dict's arbitrary contents + yield PathStep(segment, None, "opaque", is_last) + continue + field = current_model.model_fields.get(segment) + if field is None: + yield PathStep(segment, None, "unknown", is_last) + current_model = None + continue + kind, nested_model = _classify(field.annotation) + yield PathStep(segment, field, kind, is_last) + current_model = nested_model if kind == "model" else None + + +def _validate_path(model: type[BaseModel], path: str) -> None: + """Raise if the dotted path is not a selectable field path on the model.""" + for step in _walk_path(model, path): + if step.kind == "unknown": + raise ValidationError(f"unknown field in _fields: {path!r} (no field {step.segment!r})") + if step.kind in ("scalar", "list") and not step.is_last: + raise ValidationError(f"cannot select subfields of {step.kind} field {step.segment!r} in _fields: {path!r}") + + +def _collapse(paths: frozenset[str]) -> frozenset[str]: + """Drop any path whose segment-prefix is also requested (a whole field subsumes its parts). + + ie. {stats, stats.count} => {stats} + """ + segments_by_path = {path: tuple(path.split(".")) for path in paths} + return frozenset( + path + for path, segments in segments_by_path.items() + if not any( + other_path != path and segments[: len(other_segments)] == other_segments + for other_path, other_segments in segments_by_path.items() + ) + ) + + +def _mongo_key(model: type[BaseModel], path: str) -> str: + """Translate a dotted field-name path into its alias-resolved Mongo-key path.""" + mongo_segments: list[str] = [] + for step in _walk_path(model, path): + alias = step.field.validation_alias if step.field is not None else None + mongo_segments.append(alias if isinstance(alias, str) else step.segment) + return ".".join(mongo_segments) + + +def _backs_mongo_id(field: FieldInfo) -> bool: + """Whether a field maps to Mongo ``_id`` (by alias), regardless of its name or type.""" + return field.validation_alias == "_id" or field.alias == "_id" + + +def _optional_field(source_field: FieldInfo, annotation: Any) -> tuple[Any, FieldInfo]: + """Build an optional create_model field definition, preserving the source field's aliases.""" + validation_alias = source_field.validation_alias if isinstance(source_field.validation_alias, str) else None + serialization_alias = ( + source_field.serialization_alias if isinstance(source_field.serialization_alias, str) else None + ) + optional_annotation: Any = annotation | None + return optional_annotation, FieldInfo( + default=None, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + ) + + +@lru_cache(maxsize=128) +def _build_model[ModelT: BaseModel](model: type[ModelT], paths: frozenset[str]) -> type[ModelT]: + """Recursively build the partial response model covering the requested paths.""" + nested_paths_by_root: dict[str, set[str]] = {} + for path in paths: + root, _, remainder = path.partition(".") + nested_paths = nested_paths_by_root.setdefault(root, set()) + if remainder: + nested_paths.add(remainder) + + field_definitions: dict[str, Any] = {} + for root, nested_paths in nested_paths_by_root.items(): + source_field = model.model_fields[root] + kind, nested_model = _classify(source_field.annotation) + if not nested_paths: + field_definitions[root] = _optional_field(source_field, source_field.annotation) + elif kind == "model" and nested_model is not None: + partial_nested = _build_model(nested_model, frozenset(nested_paths)) + field_definitions[root] = _optional_field(source_field, partial_nested) + elif kind == "dict": + field_definitions[root] = _optional_field(source_field, dict[str, Any]) + else: + raise ValidationError(f"cannot project subfields of {kind} field {root!r}") + + partial_model = create_model( + f"{model.__name__}Projection", + __config__=model.model_config, + **field_definitions, + ) + return cast("type[ModelT]", partial_model) + + +@lru_cache(maxsize=128) +def _build_projection[ModelT: BaseModel](model: type[ModelT], paths: frozenset[str]) -> type[ModelT]: + """Build the partial model and attach its explicit dotted Mongo projection.""" + projection: dict[str, int] = {"_id": 1} + for path in paths: + projection[_mongo_key(model, path)] = 1 + partial_model = _build_model(model, paths) + partial_model.Settings = type("Settings", (), {"projection": projection}) + return partial_model + + +class SparseFieldsModel(BaseModel): + """Mixin for response models that support ``_fields`` projection. + + The subclass is the public projectable surface (e.g. ``ProjectOut``); its + field names are the valid ``_fields`` vocabulary, and dotted paths descend + into nested models (``stats.size``) or into arbitrary dict fields + (``data.var.x``). Any field backing Mongo ``_id`` must be declared + ``Field(alias="_id", serialization_alias="id")`` so the projection targets + the right key while the response serialises to the public name. + """ + + # Field names forced into every projection (identity / cursor keys). + sparse_always: ClassVar[frozenset[str]] = frozenset() + + @classmethod + def _identity_fields(cls) -> frozenset[str]: + """Field names backing Mongo ``_id``, always forced into a projection.""" + return frozenset(name for name, field in cls.model_fields.items() if _backs_mongo_id(field)) + + @classmethod + def field_names(cls) -> frozenset[str]: + """Return the top-level field names that may appear in ``_fields``.""" + return frozenset(cls.model_fields) + + @classmethod + def parse_fields(cls, raw: list | None) -> frozenset[str] | None: + """Validate and normalise a raw ``_fields`` value into a set of paths. + + Args: + raw (list): The list of field paths from the ``_fields`` query parameter, + or None when the query parameter was omitted. + + Returns: + None when every field should be returned (parameter omitted), + otherwise the validated, collapsed set of dotted paths, always + including this model's ``sparse_always`` fields. + + Raises: + ValidationError: If a requested path names an unknown field or + selects subfields of a scalar or list field. + """ + if not raw: + return None # None == all fields + requested = frozenset(name.strip() for name in raw if name.strip()) + for path in requested: + _validate_path(cls, path) + return _collapse(requested | cls.sparse_always | cls._identity_fields()) + + @classmethod + def projection(cls, fields: frozenset[str] | None) -> type[Self]: + """Return a projection model exposing only the requested fields. + + Args: + fields: The collapsed path set from ``parse_fields``, or None to + project every field. + + Returns: + This model unchanged when ``fields`` is None, otherwise a cached + partial model carrying an explicit dotted Mongo projection in its + ``Settings``. + """ + if fields is None: + return cls + return cast("type[Self]", _build_projection(cls, fields)) diff --git a/mpcontribs-api/supervisord/conf.py b/mpcontribs-api/supervisord/conf.py index fb0dde081..6869eb183 100644 --- a/mpcontribs-api/supervisord/conf.py +++ b/mpcontribs-api/supervisord/conf.py @@ -1,39 +1,36 @@ import os + from jinja2 import Environment, FileSystemLoader DIR = os.path.abspath(os.path.dirname(__file__)) PRODUCTION = int(os.environ.get("PRODUCTION", "1")) -DEFAULT_NWORKERS = 2 if PRODUCTION else 1 -NWORKERS = int(os.environ.get("NWORKERS", DEFAULT_NWORKERS)) -KG_PORT = 10100 +# uvicorn worker count per API process. Same default in dev and prod; override via the NWORKERS env var. +NWORKERS = int(os.environ.get("NWORKERS", "2")) deployments = {} -for deployment in os.environ.get("DEPLOYMENTS", "ml:10002").split(","): - name, db, s3, tm, max_projects, api_port = deployment.split(":") - portal_port = 8080 + int(api_port) % 10000 +# DEPLOYMENTS entries are "name:db:s3:tm:max_projects:api_port" — the format is an external +# deployment contract (also parsed by scripts/healthchecks.py). Only name, db, and api_port are +# consumed today; s3/tm/max_projects are Flask/portal-era fields kept for format compatibility. +for deployment in os.environ.get("DEPLOYMENTS", "ml:ml:ml:MP:3:10002").split(","): + name, db, _s3, _tm, _max_projects, api_port = deployment.split(":") deployments[name] = { "api_port": api_port, - "portal_port": portal_port, "db": db, - "s3": s3, - "tm": tm.upper(), - "max_projects": int(max_projects) if max_projects else 3 } kwargs = { "production": PRODUCTION, + # MPCONTRIBS_ENVIRONMENT drives the new pydantic Settings (log format, debug mode). + "environment": "prod" if PRODUCTION else "dev", + # MPCONTRIBS_VERSION is required by Settings; sourced from the image build arg at container start. + "version": os.environ.get("CONTRIBS_VERSION", os.environ.get("MPCONTRIBS_VERSION", "0.0.0")), "deployments": deployments, + # Consumed by scripts/start.sh: both dev and prod run `uvicorn --workers $NWORKERS`. "nworkers": NWORKERS, - "reload": int(not PRODUCTION), - "node_env": "production" if PRODUCTION else "development", - "flask_log_level": "INFO" if PRODUCTION else "DEBUG", - "jupyter_gateway_host": f"localhost:{KG_PORT}" if PRODUCTION else f"kernel-gateway:{KG_PORT}", - "dd_agent_host": "localhost" if PRODUCTION else "datadog", - "mpcontribs_api_host": "localhost" if PRODUCTION else "contribs-apis", + # OTLP/gRPC receiver: the Datadog Agent sidecar in prod, the "datadog" compose service in dev. + "otel_endpoint": "localhost:4317" if PRODUCTION else "datadog:4317", } -kwargs["flask_debug"] = kwargs["node_env"] == "development" -kwargs["jupyter_gateway_url"] = "http://" + kwargs["jupyter_gateway_host"] env = Environment(loader=FileSystemLoader(DIR)) template = env.get_template("supervisord.conf.jinja") diff --git a/mpcontribs-api/supervisord/supervisord.conf.jinja b/mpcontribs-api/supervisord/supervisord.conf.jinja index 320d7493e..4e26c0117 100644 --- a/mpcontribs-api/supervisord/supervisord.conf.jinja +++ b/mpcontribs-api/supervisord/supervisord.conf.jinja @@ -4,33 +4,19 @@ user=root logfile=/tmp/supervisord.log pidfile=/tmp/supervisord.pid environment= - MPCONTRIBS_MONGO_HOST="%(ENV_MPCONTRIBS_MONGO_HOST)s", + MPCONTRIBS_ENVIRONMENT="{{ environment }}", + MPCONTRIBS_VERSION="{{ version }}", {% if not production %} AWS_ACCESS_KEY_ID="%(ENV_AWS_ACCESS_KEY_ID)s", AWS_SECRET_ACCESS_KEY="%(ENV_AWS_SECRET_ACCESS_KEY)s", {% endif %} METADATA_URI="%(ENV_ECS_CONTAINER_METADATA_URI_V4)s", - REDIS_ADDRESS="%(ENV_REDIS_ADDRESS)s", AWS_REGION="us-east-1", AWS_DEFAULT_REGION="us-east-1", - MAIL_DEFAULT_SENDER="contribs@materialsproject.org", - DD_PROFILING_ENABLED="false", - DD_PROFILING_STACK_V2_ENABLED="false", - DD_LOGS_INJECTION="true", - FLASK_APP="mpcontribs.api", + MPCONTRIBS_MAIL_DEFAULT_SENDER="contribs@materialsproject.org", PYTHONUNBUFFERED=1, - MAX_REQUESTS=0, - MAX_REQUESTS_JITTER=0, NWORKERS={{ nworkers }}, - RELOAD={{ reload }}, - NODE_ENV="{{ node_env }}", - FLASK_DEBUG="{{ flask_debug }}", - FLASK_LOG_LEVEL="{{ flask_log_level }}", - DD_LOG_LEVEL="{{ flask_log_level }}", - JUPYTER_GATEWAY_URL="{{ jupyter_gateway_url }}", - JUPYTER_GATEWAY_HOST="{{ jupyter_gateway_host }}", - DD_AGENT_HOST="{{ dd_agent_host }}", - DD_TRACE_SAMPLE_RATE="1", + MPCONTRIBS_OTEL__OTLP_ENDPOINT="{{ otel_endpoint }}", TINI_SUBREAPER="true" [program:main] @@ -75,15 +61,7 @@ stderr_logfile_maxbytes=0 environment= DEPLOYMENT={{ loop.index0 }}, API_PORT={{ cfg.api_port }}, - PORTAL_PORT={{ cfg.portal_port }}, - MPCONTRIBS_API_HOST="{{ mpcontribs_api_host }}:{{ cfg.api_port }}", - MPCONTRIBS_DB_NAME="mpcontribs-{{ cfg.db }}", - TRADEMARK="{{ cfg.tm }}", - MAX_PROJECTS={{ cfg.max_projects }}, - S3_DOWNLOADS_BUCKET="mpcontribs-downloads-{{ cfg.s3 }}", - S3_ATTACHMENTS_BUCKET="mpcontribs-attachments-{{ cfg.s3 }}", - S3_IMAGES_BUCKET="mpcontribs-images-{{ cfg.s3 }}", - ADMIN_GROUP="admin_{{ deployment }}.materialsproject.org" + MPCONTRIBS_MONGO__ADMIN_GROUP="admin_{{ deployment }}.materialsproject.org" {% endset %} [program:{{ deployment }}-worker] diff --git a/mpcontribs-api/mpcontribs/api/structures/__init__.py b/mpcontribs-api/tests/__init__.py similarity index 100% rename from mpcontribs-api/mpcontribs/api/structures/__init__.py rename to mpcontribs-api/tests/__init__.py diff --git a/mpcontribs-api/tests/conftest.py b/mpcontribs-api/tests/conftest.py new file mode 100644 index 000000000..617139b13 --- /dev/null +++ b/mpcontribs-api/tests/conftest.py @@ -0,0 +1,18 @@ +import os + +from dotenv import load_dotenv + +# Load .env *before* setdefault calls so real credentials take precedence. +# load_dotenv is a no-op when .env doesn't exist (CI / pure unit-test runs). +load_dotenv() + +# Fallbacks for any value not supplied by .env (CI, unit-only runs, etc.) +os.environ.setdefault("MPCONTRIBS_ENVIRONMENT", "dev") +os.environ.setdefault("MPCONTRIBS_MONGO__URI", "mongodb://localhost:27017") +os.environ.setdefault("MPCONTRIBS_MONGO__DB_NAME", "testdb") +os.environ.setdefault("MPCONTRIBS_REDIS__ADDRESS", "redis://localhost:6379") +os.environ.setdefault("MPCONTRIBS_REDIS__URL", "redis://localhost:6379") +os.environ.setdefault("MPCONTRIBS_MAIL_DEFAULT_SENDER", "test@example.com") +os.environ.setdefault("MPCONTRIBS_VERSION", "0.0.0-test") +# No OTLP collector in tests: keep telemetry off so the suite doesn't register providers or export. +os.environ.setdefault("MPCONTRIBS_OTEL__ENABLED", "false") diff --git a/mpcontribs-api/tests/integration/__init__.py b/mpcontribs-api/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mpcontribs-api/tests/integration/conftest.py b/mpcontribs-api/tests/integration/conftest.py new file mode 100644 index 000000000..c4be40b21 --- /dev/null +++ b/mpcontribs-api/tests/integration/conftest.py @@ -0,0 +1,131 @@ +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from mpcontribs_api.exceptions import register_exception_handlers +from mpcontribs_api.middleware import RequestContextMiddleware + + +@pytest.fixture(autouse=True, scope="session") +def _mock_beanie_collection(): + """Stub Beanie's collection check for mock-based integration tests. + + FastAPI parses request bodies into Beanie Document subclasses (e.g. + ProjectIn), which calls get_pymongo_collection() in __init__. Without a + real init_beanie() these raise CollectionWasNotInitialized. + + The tests/integration/db/ conftest overrides this fixture with a no-op so + DB tests still get the real Beanie collection after init_beanie(). + """ + import beanie + + with patch.object(beanie.Document, "get_pymongo_collection", return_value=MagicMock()): + yield + +# --------------------------------------------------------------------------- +# Header constants used across test modules +# --------------------------------------------------------------------------- + +from mpcontribs_api.config import get_settings + +ANON_HEADERS: dict[str, str] = {} + +# Forces anonymity even when the client carries default auth headers: get_user() +# treats x-anonymous-consumer == "true" as anonymous regardless of other headers. +FORCE_ANON_HEADERS = {"x-anonymous-consumer": "true"} + +AUTHED_HEADERS = { + "x-consumer-username": "google:alice@example.com", + "x-consumer-id": "test-consumer-id", + "x-authenticated-groups": "mp-team", +} + +ADMIN_HEADERS = { + "x-consumer-username": "google:admin@example.com", + "x-consumer-id": "test-admin-id", + "x-authenticated-groups": "admin", +} + + +# --------------------------------------------------------------------------- +# App factories +# --------------------------------------------------------------------------- + + +def make_test_app() -> FastAPI: + """Build a fully-wired FastAPI app suitable for integration tests. + + Uses a no-op lifespan so no MongoDB connection is required. The + verify_gateway dependency is NOT added at the app level here — tests that + need gateway enforcement should use make_gateway_app() instead. + """ + + @asynccontextmanager + async def _noop_lifespan(app: FastAPI): + app.state.db = MagicMock() + app.state.s3 = MagicMock() + yield + + app = FastAPI(title="mpcontribs-test", lifespan=_noop_lifespan) + app.add_middleware(RequestContextMiddleware) + register_exception_handlers(app) + + from mpcontribs_api.api.v1.router import router as v1_router + from mpcontribs_api.domains._redirects.router import router as redirects_router + + app.include_router(v1_router, prefix="/api/v1") + # Mounted last (root path), exactly as in the real app, so legacy-endpoint + # redirect/deprecation behaviour is exercised by integration tests. + app.include_router(redirects_router) + return app + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def test_app() -> FastAPI: + return make_test_app() + + +@pytest.fixture +def client(test_app: FastAPI): + """Function-scoped client; dependency overrides are cleared after each test.""" + with TestClient(test_app, raise_server_exceptions=False) as c: + yield c + test_app.dependency_overrides.clear() + + +@pytest.fixture +def gateway_client(gateway_app: FastAPI): + with TestClient(gateway_app, raise_server_exceptions=False) as c: + yield c + gateway_app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Mock repository factories +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_project_repo() -> AsyncMock: + """Fully async mock of MongoDbProjectRepository.""" + return AsyncMock() + + +@pytest.fixture +def mock_contribution_repo() -> AsyncMock: + """Fully async mock of MongoDbContributionRepository.""" + return AsyncMock() + + +@pytest.fixture +def mock_contribution_service() -> AsyncMock: + """Fully async mock of ContributionService.""" + return AsyncMock() diff --git a/mpcontribs-api/tests/integration/db/__init__.py b/mpcontribs-api/tests/integration/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mpcontribs-api/tests/integration/db/conftest.py b/mpcontribs-api/tests/integration/db/conftest.py new file mode 100644 index 000000000..beab815b9 --- /dev/null +++ b/mpcontribs-api/tests/integration/db/conftest.py @@ -0,0 +1,99 @@ +import pytest +import pytest_asyncio +from beanie import init_beanie +from pymongo import AsyncMongoClient + +from mpcontribs_api.config import get_settings +from mpcontribs_api.domains.attachments.models import Attachment +from mpcontribs_api.domains.contributions.models import Contribution +from mpcontribs_api.domains.projects.models import Project +from mpcontribs_api.domains.structures.models import Structure +from mpcontribs_api.domains.tables.models import Table + +# --------------------------------------------------------------------------- +# Auto-mark all tests in this directory as @pytest.mark.db +# --------------------------------------------------------------------------- + +pytestmark = [ + pytest.mark.db, + pytest.mark.asyncio(loop_scope="session"), +] + + +@pytest.fixture(scope="session") +def _mock_beanie_collection(): + """Override the parent integration conftest's Beanie mock. + + DB tests initialise Beanie for real via init_beanie(), so the mock must not + intercept get_pymongo_collection(). Defining this fixture here (same name, + no patch) causes pytest to use this no-op instead of the parent's version. + """ + yield + + +def pytest_collection_modifyitems(items): + for item in items: + if "integration/db" in str(item.fspath): + item.add_marker(pytest.mark.db) + item.add_marker(pytest.mark.asyncio(loop_scope="session")) + + +# --------------------------------------------------------------------------- +# Session-scoped MongoDB connection + Beanie initialization +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture(scope="session") +async def mongo_client(): + """Connect to the Atlas dev instance; skip if unreachable.""" + settings = get_settings() + client = AsyncMongoClient( + settings.mongo.uri.get_secret_value(), + serverSelectionTimeoutMS=5_000, + ) + try: + await client.admin.command("ping") + except Exception as exc: + pytest.skip(f"MongoDB not reachable: {exc}") + yield client + await client.close() + + +@pytest_asyncio.fixture(scope="session") +async def db(mongo_client): + """Database handle with Beanie initialised against the test database.""" + settings = get_settings() + database = mongo_client[settings.mongo.db_name] + await init_beanie( + database=database, + document_models=[Project, Contribution, Structure, Table, Attachment], + ) + yield database + + +# --------------------------------------------------------------------------- +# Per-test collection cleanup (autouse so every test starts clean) +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture(autouse=True) +async def clean_projects(db): + await db["projects"].delete_many({}) + yield + await db["projects"].delete_many({}) + + +@pytest_asyncio.fixture(autouse=True) +async def clean_contributions(db): + await db["contributions"].delete_many({}) + yield + await db["contributions"].delete_many({}) + + +@pytest_asyncio.fixture(autouse=True) +async def clean_components(db): + for collection in ("structures", "tables", "attachments"): + await db[collection].delete_many({}) + yield + for collection in ("structures", "tables", "attachments"): + await db[collection].delete_many({}) diff --git a/mpcontribs-api/tests/integration/db/test_component_reachability.py b/mpcontribs-api/tests/integration/db/test_component_reachability.py new file mode 100644 index 000000000..0f7faf088 --- /dev/null +++ b/mpcontribs-api/tests/integration/db/test_component_reachability.py @@ -0,0 +1,86 @@ +"""End-to-end reachability gating for component reads. + +Components (structures/tables/attachments) carry no access field of their own. Visibility is +gated by whether a contribution the caller can see references the component. These tests drive the +real ComponentService against MongoDB to confirm reads only surface reachable components. +""" + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.authz import User +from mpcontribs_api.domains._shared.service import ComponentService +from mpcontribs_api.domains.attachments.models import Attachment, AttachmentFilter +from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository +from mpcontribs_api.domains.contributions.models import Contribution +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.pagination import CursorParams + +pytestmark = [pytest.mark.db, pytest.mark.asyncio(loop_scope="session")] + +ANON = User() + + +def _service(user: User) -> ComponentService: + return ComponentService( + MongoDbAttachmentRepository(user), + MongoDbContributionRepository(user), + ref_field="attachments", + ) + + +async def _attachment(content: int) -> Attachment: + # md5 is server-computed from (mime, content); distinct content -> distinct md5/dedup. + doc = Attachment(_id=PydanticObjectId(), name="d.csv", mime="application/gzip", content=content) + await doc.insert() + return doc + + +async def _contribution(identifier: str, *, is_public: bool, attachments: list[Attachment]) -> Contribution: + doc = Contribution( + _id=PydanticObjectId(), + project="reach-proj", + identifier=identifier, + formula="Fe2O3", + data={"x": 1}, + is_public=is_public, + attachments=attachments, + ) + await doc.insert() + return doc + + +class TestComponentReadReachability: + async def test_get_by_id_returns_reachable_component(self, db): + att = await _attachment(1) + await _contribution("mp-pub", is_public=True, attachments=[att]) + result = await _service(ANON).get_by_id(str(att.id), fields=None) + assert result is not None + assert result.id == att.id + + async def test_get_by_id_hides_unreachable_component(self, db): + att = await _attachment(2) + # Referenced only by a private contribution -> anonymous cannot reach it. + await _contribution("mp-priv", is_public=False, attachments=[att]) + result = await _service(ANON).get_by_id(str(att.id), fields=None) + assert result is None + + async def test_get_by_id_hides_orphan_component(self, db): + # No contribution references this attachment at all. + att = await _attachment(3) + result = await _service(ANON).get_by_id(str(att.id), fields=None) + assert result is None + + async def test_get_many_only_lists_reachable(self, db): + pub = await _attachment(10) + priv = await _attachment(20) + orphan = await _attachment(30) + await _contribution("mp-a", is_public=True, attachments=[pub]) + await _contribution("mp-b", is_public=False, attachments=[priv]) + + page = await _service(ANON).get_many(filter=AttachmentFilter(), pagination=CursorParams(), fields=None) + ids = {item.id for item in page.items} + + assert pub.id in ids + assert priv.id not in ids + assert orphan.id not in ids diff --git a/mpcontribs-api/tests/integration/db/test_components_repository.py b/mpcontribs-api/tests/integration/db/test_components_repository.py new file mode 100644 index 000000000..8a3845aaf --- /dev/null +++ b/mpcontribs-api/tests/integration/db/test_components_repository.py @@ -0,0 +1,223 @@ +import gzip +from unittest.mock import MagicMock + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.authz import User +from mpcontribs_api.config import get_settings +from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat +from mpcontribs_api.domains.attachments.models import ( + Attachment, + AttachmentFilter, + AttachmentIn, + AttachmentPatch, +) +from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository + +pytestmark = [pytest.mark.db, pytest.mark.asyncio(loop_scope="session")] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +USER = User(username="google:alice@example.com", groups=frozenset({"mp-team"})) + + +def _repo() -> MongoDbAttachmentRepository: + return MongoDbAttachmentRepository(USER) + + +def _attachment(content: int = 1, name: str = "data.csv") -> AttachmentIn: + # md5 is server-computed from (mime, content), so `content` drives dedup identity. + return AttachmentIn(name=name, mime="application/gzip", content=content) + + +async def _count() -> int: + return await Attachment.find_all().count() + + +# --------------------------------------------------------------------------- +# insert_components: md5 dedupe +# --------------------------------------------------------------------------- + + +class TestInsertComponentsDedupe: + async def test_duplicate_content_in_batch_inserted_once(self, db): + # Two inputs share content (-> same md5); only one document should be written. + await _repo().insert_components([_attachment(1), _attachment(1), _attachment(2)]) + assert await _count() == 2 + + async def test_returns_one_doc_per_unique_md5(self, db): + result = await _repo().insert_components([_attachment(1), _attachment(1)]) + assert len(result) == 1 + + async def test_existing_md5_not_reinserted(self, db): + await _repo().insert_components([_attachment(1)]) + # Re-submit the existing content alongside new content. + await _repo().insert_components([_attachment(1), _attachment(2)]) + assert await _count() == 2 + + async def test_existing_doc_returned_with_original_id(self, db): + first = await _repo().insert_components([_attachment(1)]) + again = await _repo().insert_components([_attachment(1)]) + assert again[0].id == first[0].id + + async def test_inserted_docs_have_ids(self, db): + result = await _repo().insert_components([_attachment(1), _attachment(2)]) + assert all(doc.id is not None for doc in result) + + +# --------------------------------------------------------------------------- +# insert_components: chunking +# --------------------------------------------------------------------------- + + +class TestInsertComponentsChunking: + async def test_all_docs_persisted_across_multiple_chunks(self, db, monkeypatch): + # Force a chunk size smaller than the batch so the chunking loop runs >1 time. + monkeypatch.setattr(get_settings().mongo, "component_insert_chunk_size", 2) + # Distinct content -> distinct md5 so all five survive dedup. + attachments = [_attachment(i) for i in range(5)] + result = await _repo().insert_components(attachments) + assert len(result) == 5 + assert await _count() == 5 + + +# --------------------------------------------------------------------------- +# insert_component (single) +# --------------------------------------------------------------------------- + + +class TestInsertComponent: + async def test_single_insert_persists(self, db): + doc = await _repo().insert_component(_attachment(3)) + found = await Attachment.find_one(Attachment.id == doc.id) + assert found is not None + assert found.md5 == doc.md5 + assert len(found.md5) == 32 + + +# --------------------------------------------------------------------------- +# delete_components / delete_component_by_id +# --------------------------------------------------------------------------- + + +class TestDeleteComponents: + async def test_filtered_delete_removes_only_matches(self, db): + keep, drop = await _repo().insert_components([_attachment(1), _attachment(2)]) + result = await _repo().delete_components(AttachmentFilter(md5=drop.md5)) + assert result.num_deleted == 1 + remaining = {doc.md5 async for doc in Attachment.find_all()} + assert remaining == {keep.md5} + + async def test_delete_by_id_removes_one(self, db): + """delete_component_by_id matches a string id by converting it to ObjectId.""" + [doc] = await _repo().insert_components([_attachment(1)]) + result = await _repo().delete_component_by_id(str(doc.id)) + assert result.num_deleted == 1 + assert await _count() == 0 + + async def test_delete_by_unknown_id_raises(self, db): + from mpcontribs_api.exceptions import NotFoundError + + with pytest.raises(NotFoundError): + await _repo().delete_component_by_id(str(PydanticObjectId())) + + +# --------------------------------------------------------------------------- +# patch_component_by_id +# --------------------------------------------------------------------------- + + +class TestPatchComponent: + async def test_patch_updates_field(self, db): + [doc] = await _repo().insert_components([_attachment(1, name="data.csv")]) + updated = await _repo().patch_component_by_id(str(doc.id), AttachmentPatch(name="renamed.png")) + assert updated.name == "renamed.png" + + async def test_empty_patch_returns_existing(self, db): + [doc] = await _repo().insert_components([_attachment(1, name="data.csv")]) + updated = await _repo().patch_component_by_id(str(doc.id), AttachmentPatch()) + assert updated.id == doc.id + + async def test_patch_content_recomputes_md5(self, db): + # name is not a hash field, so renaming must NOT change md5. + [doc] = await _repo().insert_components([_attachment(1)]) + renamed = await _repo().patch_component_by_id(str(doc.id), AttachmentPatch(name="renamed.png")) + assert renamed.md5 == doc.md5 + # content IS a hash field, so changing it must recompute md5. + rehashed = await _repo().patch_component_by_id(str(doc.id), AttachmentPatch(content=999)) + assert rehashed.md5 != doc.md5 + persisted = await Attachment.find_one(Attachment.id == doc.id) + assert persisted.md5 == rehashed.md5 + + +# --------------------------------------------------------------------------- +# Component download round-trip +# --------------------------------------------------------------------------- + + +class TestComponentDownload: + async def test_jsonl_download_round_trips(self, db): + """Component downloads stream a decompressable gzip of all rows.""" + await _repo().insert_components([_attachment(1), _attachment(2)]) + stream = _repo().download( + format=DownloadFormat.JSONL, + short_mime=ShortMimeFormat.GZ, + ignore_cache=True, + filter=AttachmentFilter(), + fields=None, + s3=MagicMock(), + bucket_name="attachments", + key_name="", + ) + chunks = [c async for c in stream] + decompressed = gzip.decompress(b"".join(chunks)) + assert decompressed.count(b"\n") == 2 + + +# --------------------------------------------------------------------------- +# Table DataFrame <-> stored (index/columns/data) round-trips through Mongo +# --------------------------------------------------------------------------- + + +class TestTableFrameRoundTrip: + async def test_table_frame_round_trips_via_storage_shape(self, db): + import polars as pl + + from mpcontribs_api.authz import User + from mpcontribs_api.domains.tables.models import TableIn, TableOut + from mpcontribs_api.domains.tables.repository import MongoDbTableRepository + + repo = MongoDbTableRepository(User(username="x", groups=frozenset())) + # First column is the index (named "T [K]"); cells stay as the original formatted strings. + frame = pl.DataFrame( + { + "T [K]": ["100.0", "200.0"], + "1e16": ["2.2718689×10²¹", "2.2745466×10²¹"], + "1e17": ["2.2718684×10²¹", "2.2745438×10²¹"], + } + ) + tin = TableIn( + name="σ(p)", + attrs={"title": "g", "labels": {"index": "T [K]", "value": "σ", "variable": "doping"}}, + data=frame, + ) + [doc] = await repo.insert_components([tin]) + + # Stored in the canonical MongoDB shape: index/columns/data as strings. + raw = await db["tables"].find_one({"_id": doc.id}) + assert raw["index"] == ["100.0", "200.0"] + assert raw["columns"] == ["1e16", "1e17"] + assert raw["data"] == [["2.2718689×10²¹", "2.2718684×10²¹"], ["2.2745466×10²¹", "2.2745438×10²¹"]] + assert raw["total_data_rows"] == 2 + + # Read back: reassembled into the same DataFrame (index folded back as the first column). + out = await repo.get_component_by_id(str(doc.id), TableOut.parse_fields(["data"])) + assert out.data.columns == ["T [K]", "1e16", "1e17"] + assert out.data.equals(frame) + # The raw storage keys must not leak onto the response model. + assert "index" not in out.model_dump() + await db["tables"].delete_many({"_id": doc.id}) diff --git a/mpcontribs-api/tests/integration/db/test_contributions_repository.py b/mpcontribs-api/tests/integration/db/test_contributions_repository.py new file mode 100644 index 000000000..4d3cd97c8 --- /dev/null +++ b/mpcontribs-api/tests/integration/db/test_contributions_repository.py @@ -0,0 +1,517 @@ +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.authz import User +from mpcontribs_api.domains.contributions.models import ( + Contribution, + ContributionFilter, + ContributionIn, + ContributionOut, + ContributionPatch, +) +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository +from mpcontribs_api.exceptions import NotFoundError, ValidationError +from mpcontribs_api.pagination import CursorParams + +pytestmark = [pytest.mark.db, pytest.mark.asyncio(loop_scope="session")] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ADMIN = User(username="google:admin@example.com", groups=frozenset({"admin"})) +ALICE = User(username="google:alice@example.com", groups=frozenset({"mp-team"})) +BOB = User(username="google:bob@example.com", groups=frozenset()) +ANON = User() + + +def _repo(user: User = ADMIN) -> MongoDbContributionRepository: + return MongoDbContributionRepository(user) + + +def _contrib_in(project: str = "test-proj", identifier: str = "mp-1", **overrides) -> ContributionIn: + defaults: dict = { + "_id": PydanticObjectId(), + "project": project, + "identifier": identifier, + "formula": "Fe2O3", + "data": {"band_gap": 2.1}, + } + defaults.update(overrides) + return ContributionIn(**defaults) + + +async def _insert(project="test-proj", identifier="mp-1", is_public: bool = False, **overrides) -> Contribution: + # Build a Contribution directly so is_public can be set explicitly. + # from_input_model() always forces is_public=False, which is correct for + # user-submitted data but inconvenient for test setup. + doc = Contribution( + _id=PydanticObjectId(), + project=project, + identifier=identifier, + formula=overrides.pop("formula", "Fe2O3"), + data=overrides.pop("data", {"band_gap": 2.1}), + is_public=is_public, + **overrides, + ) + await doc.insert() + return doc + + +def _noop_filter() -> ContributionFilter: + return ContributionFilter() + + +# --------------------------------------------------------------------------- +# insert_contribution (single) +# --------------------------------------------------------------------------- + + +class TestInsertContribution: + async def test_inserted_document_is_retrievable(self, db): + doc = await _insert(identifier="ins-basic") + found = await Contribution.find_one(Contribution.id == doc.id) + assert found is not None + assert found.identifier == "ins-basic" + + async def test_is_public_defaults_to_false(self, db): + doc = await _insert(identifier="ins-priv") + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.is_public is False + + async def test_fields_are_persisted(self, db): + doc = await _insert(project="proj-x", identifier="ins-fields", formula="Li2O", data={"x": 1}) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.project == "proj-x" + assert found.formula == "Li2O" + assert found.data == {"x": 1} + + async def test_insert_via_repo(self, db): + ci = _contrib_in(identifier="ins-via-repo") + doc = Contribution.from_input_model(ci) + result = await _repo().insert_contribution(doc) + found = await Contribution.find_one(Contribution.id == result.id) + assert found is not None + assert found.identifier == "ins-via-repo" + + +# --------------------------------------------------------------------------- +# insert_many_contributions (bulk) +# --------------------------------------------------------------------------- + + +class TestInsertManyContributions: + async def test_all_docs_persisted(self, db): + docs = [Contribution.from_input_model(_contrib_in(identifier=f"bulk-{i}")) for i in range(5)] + await _repo().insert_many_contributions(docs) + for doc in docs: + found = await Contribution.find_one(Contribution.id == doc.id) + assert found is not None + + async def test_returns_insert_result(self, db): + docs = [Contribution.from_input_model(_contrib_in(identifier=f"bulk-ret-{i}")) for i in range(3)] + result = await _repo().insert_many_contributions(docs) + assert result is not None + + async def test_empty_list_raises_type_error(self, db): + # Motor's insert_many requires at least one document; callers are + # responsible for guarding against empty batches. + with pytest.raises(TypeError, match="non-empty"): + await _repo().insert_many_contributions([]) + + +# --------------------------------------------------------------------------- +# get_contributions (scoped list + pagination + projection) +# --------------------------------------------------------------------------- + + +class TestGetContributions: + async def test_admin_sees_private_and_public(self, db): + p = await _insert(identifier="ga-pub", is_public=True) + pr = await _insert(identifier="ga-priv", is_public=False) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=_noop_filter(), fields=None + ) + ids = {str(c.id) for c in page.items} + assert str(p.id) in ids + assert str(pr.id) in ids + + async def test_anonymous_sees_only_public(self, db): + pub = await _insert(identifier="anon-pub", is_public=True) + priv = await _insert(identifier="anon-priv", is_public=False) + page = await _repo(ANON).get_contributions( + pagination=CursorParams(), filter=_noop_filter(), fields=None + ) + ids = {str(c.id) for c in page.items} + assert str(pub.id) in ids + assert str(priv.id) not in ids + + async def test_authenticated_non_admin_sees_public(self, db): + pub = await _insert(identifier="alice-pub", is_public=True) + priv = await _insert(identifier="alice-priv", is_public=False) + page = await _repo(ALICE).get_contributions( + pagination=CursorParams(), filter=_noop_filter(), fields=None + ) + ids = {str(c.id) for c in page.items} + assert str(pub.id) in ids + assert str(priv.id) not in ids + + async def test_response_is_page_shape(self, db): + await _insert(identifier="pg-shape") + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=_noop_filter(), fields=None + ) + assert hasattr(page, "items") + assert hasattr(page, "next_cursor") + + async def test_limit_respected(self, db): + for i in range(5): + await _insert(identifier=f"lim-{i:02d}", is_public=True) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(limit=3), filter=_noop_filter(), fields=None + ) + assert len(page.items) <= 3 + + async def test_cursor_paginates_forward(self, db): + for i in range(4): + await _insert(identifier=f"cur-{i:02d}", is_public=True) + p1 = await _repo(ADMIN).get_contributions( + pagination=CursorParams(limit=2), filter=_noop_filter(), fields=None + ) + assert p1.next_cursor is not None + p2 = await _repo(ADMIN).get_contributions( + pagination=CursorParams(limit=2, cursor=p1.next_cursor), filter=_noop_filter(), fields=None + ) + ids1 = {str(c.id) for c in p1.items} + ids2 = {str(c.id) for c in p2.items} + assert ids1.isdisjoint(ids2) + + async def test_all_items_covered_across_pages(self, db): + for i in range(5): + await _insert(identifier=f"all-pg-{i:02d}", is_public=True) + identifiers: set[str] = set() + cursor = None + while True: + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(limit=2, cursor=cursor), filter=_noop_filter(), fields=None + ) + identifiers.update(c.identifier for c in page.items if c.identifier) + cursor = page.next_cursor + if cursor is None: + break + assert all(f"all-pg-{i:02d}" in identifiers for i in range(5)) + + async def test_next_cursor_none_on_last_page(self, db): + for i in range(2): + await _insert(identifier=f"last-pg-{i:02d}", is_public=True) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(limit=100), filter=_noop_filter(), fields=None + ) + assert page.next_cursor is None + + async def test_projection_returns_only_requested_fields(self, db): + await _insert(identifier="proj-fields", is_public=True) + fields = ContributionOut.parse_fields(["formula"]) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=_noop_filter(), fields=fields + ) + assert len(page.items) >= 1 + item = page.items[0] + assert item.formula is not None + assert not hasattr(item, "data") + + async def test_filter_by_formula(self, db): + await _insert(identifier="flt-fe", formula="Fe2O3", is_public=True) + await _insert(identifier="flt-li", formula="Li2O", is_public=True) + f = ContributionFilter(formula="Fe2O3") + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=f, fields=None + ) + formulas = {c.formula for c in page.items} + assert formulas == {"Fe2O3"} + + async def test_filter_by_identifier_ilike(self, db): + await _insert(identifier="ilike-abc", is_public=True) + await _insert(identifier="ilike-xyz", is_public=True) + f = ContributionFilter(identifier__ilike="ilike-a") + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=f, fields=None + ) + identifiers = {c.identifier for c in page.items} + assert "ilike-abc" in identifiers + assert "ilike-xyz" not in identifiers + + async def test_filter_by_is_public(self, db): + await _insert(identifier="pub-only-pub", is_public=True) + await _insert(identifier="pub-only-priv", is_public=False) + f = ContributionFilter(is_public=True) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=f, fields=None + ) + assert all(c.is_public is True for c in page.items) + + async def test_filter_by_needs_build(self, db): + await _insert(identifier="nb-true", needs_build=True, is_public=True) + await _insert(identifier="nb-false", needs_build=False, is_public=True) + f = ContributionFilter(needs_build=False) + page = await _repo(ADMIN).get_contributions( + pagination=CursorParams(), filter=f, fields=None + ) + identifiers = {c.identifier for c in page.items} + assert "nb-false" in identifiers + assert "nb-true" not in identifiers + + +# --------------------------------------------------------------------------- +# get_contribution_by_id +# --------------------------------------------------------------------------- + + +class TestGetContributionById: + async def test_returns_doc_for_valid_id(self, db): + doc = await _insert(identifier="get-id") + result = await _repo(ADMIN).get_contribution_by_id(str(doc.id), fields=None) + assert result is not None + assert result.identifier == "get-id" + + async def test_returns_none_for_missing_id(self, db): + result = await _repo(ADMIN).get_contribution_by_id(str(PydanticObjectId()), fields=None) + assert result is None + + async def test_admin_can_get_private_doc(self, db): + doc = await _insert(identifier="get-priv", is_public=False) + result = await _repo(ADMIN).get_contribution_by_id(str(doc.id), fields=None) + assert result is not None + + async def test_anon_cannot_get_private_doc(self, db): + doc = await _insert(identifier="get-anon-priv", is_public=False) + result = await _repo(ANON).get_contribution_by_id(str(doc.id), fields=None) + assert result is None + + async def test_anon_can_get_public_doc(self, db): + doc = await _insert(identifier="get-anon-pub", is_public=True) + result = await _repo(ANON).get_contribution_by_id(str(doc.id), fields=None) + assert result is not None + + async def test_raises_validation_error_for_bad_id_format(self, db): + with pytest.raises(ValidationError): + await _repo(ADMIN).get_contribution_by_id("not-an-objectid", fields=None) + + async def test_projection_limits_fields(self, db): + doc = await _insert(identifier="get-proj", is_public=True) + fields = ContributionOut.parse_fields(["formula"]) + result = await _repo(ADMIN).get_contribution_by_id(str(doc.id), fields=fields) + assert result is not None + assert result.formula == "Fe2O3" + assert not hasattr(result, "data") + + +# --------------------------------------------------------------------------- +# find_one_contribution (by project + identifier) +# --------------------------------------------------------------------------- + + +class TestFindOneContribution: + async def test_finds_existing_doc(self, db): + await _insert(project="find-proj", identifier="find-id") + result = await _repo(ADMIN).find_one_contribution("find-proj", "find-id") + assert result is not None + assert result.project == "find-proj" + assert result.identifier == "find-id" + + async def test_returns_none_for_missing_combination(self, db): + await _insert(project="miss-proj", identifier="miss-id") + result = await _repo(ADMIN).find_one_contribution("miss-proj", "wrong-id") + assert result is None + + async def test_scope_prevents_anon_finding_private(self, db): + await _insert(project="anon-scope", identifier="priv-doc", is_public=False) + result = await _repo(ANON).find_one_contribution("anon-scope", "priv-doc") + assert result is None + + async def test_scope_allows_anon_finding_public(self, db): + await _insert(project="anon-scope-pub", identifier="pub-doc", is_public=True) + result = await _repo(ANON).find_one_contribution("anon-scope-pub", "pub-doc") + assert result is not None + + async def test_project_identifier_combination_is_unique_lookup(self, db): + await _insert(project="same-proj", identifier="id-a") + await _insert(project="same-proj", identifier="id-b") + result = await _repo(ADMIN).find_one_contribution("same-proj", "id-a") + assert result is not None + assert result.identifier == "id-a" + + +# --------------------------------------------------------------------------- +# update_contribution +# --------------------------------------------------------------------------- + + +class TestUpdateContribution: + async def test_updates_single_field(self, db): + doc = await _insert(identifier="upd-formula") + await _repo(ADMIN).update_contribution(doc, {"formula": "SiO2"}) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.formula == "SiO2" + + async def test_updates_data_field(self, db): + doc = await _insert(identifier="upd-data") + await _repo(ADMIN).update_contribution(doc, {"data": {"energy": -5.0}}) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.data == {"energy": -5.0} + + async def test_unrelated_fields_unchanged(self, db): + doc = await _insert(identifier="upd-preserve", formula="Fe2O3") + await _repo(ADMIN).update_contribution(doc, {"needs_build": False}) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.formula == "Fe2O3" + + async def test_update_sets_last_modified(self, db): + doc = await _insert(identifier="upd-lm") + original_lm = doc.last_modified + await _repo(ADMIN).update_contribution(doc, {"formula": "Al2O3"}) + found = await Contribution.find_one(Contribution.id == doc.id) + # MongoDB may return naive UTC datetimes; strip timezone before comparing. + def _naive(dt): + return dt.replace(tzinfo=None) if dt.tzinfo else dt + assert _naive(found.last_modified) >= _naive(original_lm) + + +# --------------------------------------------------------------------------- +# patch_contribution_by_id +# --------------------------------------------------------------------------- + + +class TestPatchContributionById: + async def test_updates_formula(self, db): + doc = await _insert(identifier="patch-formula") + await _repo(ADMIN).patch_contribution_by_id(str(doc.id), ContributionPatch(formula="Li2O")) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.formula == "Li2O" + + async def test_unset_fields_not_overwritten(self, db): + doc = await _insert(identifier="patch-preserve", formula="Fe2O3") + await _repo(ADMIN).patch_contribution_by_id(str(doc.id), ContributionPatch(needs_build=False)) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.formula == "Fe2O3" + + async def test_empty_patch_is_a_noop(self, db): + doc = await _insert(identifier="patch-empty", formula="Fe2O3") + result = await _repo(ADMIN).patch_contribution_by_id(str(doc.id), ContributionPatch()) + assert result is not None + found = await Contribution.find_one(Contribution.id == doc.id) + assert found.formula == "Fe2O3" + + async def test_raises_validation_error_for_bad_id(self, db): + with pytest.raises(ValidationError): + await _repo(ADMIN).patch_contribution_by_id("bad-id", ContributionPatch(formula="X")) + + async def test_anon_cannot_patch_private_doc(self, db): + from mpcontribs_api.exceptions import NotFoundError + doc = await _insert(identifier="patch-anon-priv", is_public=False) + with pytest.raises(NotFoundError): + await _repo(ANON).patch_contribution_by_id(str(doc.id), ContributionPatch(formula="X")) + + +# --------------------------------------------------------------------------- +# delete_contribution_by_id +# --------------------------------------------------------------------------- + + +class TestDeleteContributionById: + async def test_deleted_doc_not_found_afterwards(self, db): + doc = await _insert(identifier="del-me") + await _repo(ADMIN).delete_contribution_by_id(str(doc.id)) + found = await Contribution.find_one(Contribution.id == doc.id) + assert found is None + + async def test_delete_nonexistent_throws_error(self, db): + with pytest.raises(NotFoundError, match="not found"): + await _repo(ADMIN).delete_contribution_by_id(str(PydanticObjectId())) + + async def test_raises_validation_error_for_bad_id(self, db): + with pytest.raises(ValidationError): + await _repo(ADMIN).delete_contribution_by_id("not-an-id") + + async def test_anon_cannot_delete_private_doc(self, db): + doc = await _insert(identifier="del-anon-priv", is_public=False) + with pytest.raises(NotFoundError, match="not found"): + await _repo(ANON).delete_contribution_by_id(str(doc.id)) + # Scope prevents anonymous from seeing the doc, so it is never deleted. + still_there = await Contribution.find_one(Contribution.id == doc.id) + assert still_there is not None + + +# --------------------------------------------------------------------------- +# delete_contributions (bulk with filter) +# --------------------------------------------------------------------------- + + +class TestDeleteContributions: + async def test_bulk_delete_all(self, db): + for i in range(3): + await _insert(identifier=f"bdel-{i:02d}") + await _repo(ADMIN).delete_contributions(_noop_filter()) + remaining = await Contribution.find().to_list() + assert len(remaining) == 0 + + async def test_bulk_delete_with_filter(self, db): + await _insert(identifier="bdel-keep", formula="Li2O") + await _insert(identifier="bdel-drop", formula="Fe2O3") + f = ContributionFilter(formula="Fe2O3") + await _repo(ADMIN).delete_contributions(f) + remaining = await Contribution.find().to_list() + assert len(remaining) == 1 + assert remaining[0].identifier == "bdel-keep" + + async def test_bulk_delete_empty_collection_is_silent(self, db): + await _repo(ADMIN).delete_contributions(_noop_filter()) + + async def test_scope_limits_what_anon_can_delete(self, db): + await _insert(identifier="bdel-scope-pub", is_public=True) + await _insert(identifier="bdel-scope-priv", is_public=False) + await _repo(ANON).delete_contributions(_noop_filter()) + # Anonymous scope: only public visible, so only the public doc is deleted. + remaining = await Contribution.find().to_list() + identifiers = {d.identifier for d in remaining} + assert "bdel-scope-priv" in identifiers + + +class TestUpsertContributionById: + async def test_insert_when_id_absent_persists_document(self, db): + new_id = PydanticObjectId() + payload = _contrib_in(identifier="ups-new", _id=new_id) + result = await _repo(ADMIN).upsert_contribution_by_id(str(new_id), payload) + # Must be the resolved document, not an un-awaited query object. + assert isinstance(result, Contribution) + stored = await Contribution.find_one(Contribution.id == new_id) + assert stored is not None + assert stored.identifier == "ups-new" + + async def test_update_when_id_present_applies_change(self, db): + existing = await _insert(identifier="ups-existing") + payload = _contrib_in(identifier="ups-existing", formula="Li2O", _id=existing.id) + result = await _repo(ADMIN).upsert_contribution_by_id(str(existing.id), payload) + assert isinstance(result, Contribution) + stored = await Contribution.find_one(Contribution.id == existing.id) + assert stored is not None + assert stored.formula == "Li2O" + + +class TestDeleteByIdsScope: + async def test_anon_cannot_delete_out_of_scope_ids(self, db): + pub = await _insert(identifier="dbi-pub", is_public=True) + priv = await _insert(identifier="dbi-priv", is_public=False) + # Anonymous scope only sees public docs; deleting both ids must spare the private one. + result = await _repo(ANON).delete_by_ids([pub.id, priv.id]) + assert result.num_deleted == 1 + remaining = {d.identifier for d in await Contribution.find().to_list()} + assert "dbi-priv" in remaining + assert "dbi-pub" not in remaining + + async def test_admin_deletes_all_ids(self, db): + a = await _insert(identifier="dbi-a", is_public=False) + b = await _insert(identifier="dbi-b", is_public=False) + result = await _repo(ADMIN).delete_by_ids([a.id, b.id]) + assert result.num_deleted == 2 diff --git a/mpcontribs-api/tests/integration/db/test_download.py b/mpcontribs-api/tests/integration/db/test_download.py new file mode 100644 index 000000000..6800689aa --- /dev/null +++ b/mpcontribs-api/tests/integration/db/test_download.py @@ -0,0 +1,149 @@ +import csv +import gzip +import io +from unittest.mock import MagicMock + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.authz import User +from mpcontribs_api.domains.contributions.models import Contribution, ContributionFilter +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository + +pytestmark = [pytest.mark.db, pytest.mark.asyncio(loop_scope="session")] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ADMIN = User(username="google:admin@example.com", groups=frozenset({"admin"})) +ALICE = User(username="google:alice@example.com", groups=frozenset({"mp-team"})) +ANON = User() + + +def _repo(user: User = ADMIN) -> MongoDbContributionRepository: + return MongoDbContributionRepository(user) + + +async def _insert(project: str, identifier: str, is_public: bool, **overrides) -> Contribution: + doc = Contribution( + _id=PydanticObjectId(), + project=project, + identifier=identifier, + formula=overrides.pop("formula", "Fe2O3"), + data=overrides.pop("data", {"band_gap": 2.1}), + is_public=is_public, + **overrides, + ) + await doc.insert() + return doc + + +async def _seed_scope_fixture() -> None: + """Three contributions spanning the three scope buckets.""" + await _insert("pub-proj", "mp-public", is_public=True) + await _insert("mp-team", "mp-group", is_public=False) + await _insert("secret", "mp-private", is_public=False) + + +async def _collect(stream) -> bytes: + chunks: list[bytes] = [] + async for chunk in stream: + chunks.append(chunk) + return b"".join(chunks) + + +async def _download_bytes(repo: MongoDbContributionRepository, *, format="jsonl", fields=None, filter=None) -> bytes: + from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat + + stream = await repo.download_contributions( + format=DownloadFormat(format), + short_mime=ShortMimeFormat.GZ, + ignore_cache=True, + filter=filter or ContributionFilter(), + fields=fields, + key_name="", + s3=MagicMock(), + ) + return gzip.decompress(await _collect(stream)) + + +def _parse_jsonl(raw: bytes) -> list[dict]: + import json + + return [json.loads(line) for line in raw.splitlines() if line] + + +def _parse_csv(raw: bytes) -> list[dict]: + return list(csv.DictReader(io.StringIO(raw.decode()))) + + +# --------------------------------------------------------------------------- +# JSONL round-trip + scope +# --------------------------------------------------------------------------- + + +class TestDownloadJsonl: + async def test_admin_downloads_all_rows(self, db): + await _seed_scope_fixture() + rows = _parse_jsonl(await _download_bytes(_repo(ADMIN))) + assert {r["identifier"] for r in rows} == {"mp-public", "mp-group", "mp-private"} + + async def test_anonymous_sees_only_public(self, db): + await _seed_scope_fixture() + rows = _parse_jsonl(await _download_bytes(_repo(ANON))) + assert {r["identifier"] for r in rows} == {"mp-public"} + + async def test_group_member_sees_public_and_group(self, db): + await _seed_scope_fixture() + rows = _parse_jsonl(await _download_bytes(_repo(ALICE))) + assert {r["identifier"] for r in rows} == {"mp-public", "mp-group"} + + async def test_rows_carry_expected_fields(self, db): + await _insert("pub-proj", "mp-1", is_public=True, formula="Li2O") + rows = _parse_jsonl(await _download_bytes(_repo(ADMIN))) + assert rows[0]["formula"] == "Li2O" + assert rows[0]["project"] == "pub-proj" + + +# --------------------------------------------------------------------------- +# Filtering +# --------------------------------------------------------------------------- + + +class TestDownloadFiltering: + async def test_filter_limits_returned_rows(self, db): + await _insert("pub-proj", "keep-me", is_public=True) + await _insert("pub-proj", "drop-me", is_public=True) + rows = _parse_jsonl( + await _download_bytes(_repo(ADMIN), filter=ContributionFilter(identifier="keep-me")) + ) + assert {r["identifier"] for r in rows} == {"keep-me"} + + async def test_empty_result_is_valid_empty_gzip(self, db): + # No documents match -> the stream is genuinely empty and decompresses to b"". + # (This path never enters the compress loop, so the missing-flush bug doesn't bite.) + raw = await _download_bytes(_repo(ADMIN), filter=ContributionFilter(identifier="no-such-id")) + assert raw == b"" + + +# --------------------------------------------------------------------------- +# CSV round-trip + field projection +# --------------------------------------------------------------------------- + + +class TestDownloadCsv: + async def test_csv_has_header_and_rows(self, db): + await _insert("pub-proj", "mp-1", is_public=True) + await _insert("pub-proj", "mp-2", is_public=True) + rows = _parse_csv(await _download_bytes(_repo(ADMIN), format="csv")) + assert len(rows) == 2 + + async def test_csv_projects_only_requested_fields(self, db): + await _insert("pub-proj", "mp-1", is_public=True) + fields = frozenset({"id", "identifier"}) + rows = _parse_csv(await _download_bytes(_repo(ADMIN), format="csv", fields=fields)) + # Only the requested columns (plus the always-present id) appear. + assert set(rows[0].keys()) <= {"id", "identifier"} + assert rows[0]["identifier"] == "mp-1" diff --git a/mpcontribs-api/tests/integration/db/test_projects_repository.py b/mpcontribs-api/tests/integration/db/test_projects_repository.py new file mode 100644 index 000000000..6ec770c1a --- /dev/null +++ b/mpcontribs-api/tests/integration/db/test_projects_repository.py @@ -0,0 +1,389 @@ +import pytest + +from mpcontribs_api.authz import User +from mpcontribs_api.domains.projects.models import Project, ProjectIn, ProjectOut, ProjectPatch, Stats +from mpcontribs_api.domains.projects.repository import MongoDbProjectRepository +from mpcontribs_api.exceptions import ConflictError, NotFoundError +from mpcontribs_api.pagination import CursorParams + +# All tests in this module share the session event loop so they can reuse the +# session-scoped AsyncMongoClient initialised in conftest. Beanie's internal +# collection references are loop-bound, so mixing loops causes errors. +pytestmark = [pytest.mark.db, pytest.mark.asyncio(loop_scope="session")] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +STATS = Stats(columns=0, contributions=0, tables=0, structures=0, attachments=0, size=0.0) + +ADMIN = User(username="google:admin@example.com", groups=frozenset({"admin"})) +ALICE = User(username="google:alice@example.com", groups=frozenset({"mp-team"})) +ANON = User() + + +def _repo(user: User) -> MongoDbProjectRepository: + return MongoDbProjectRepository(user) + + +def _project_in(id: str, **overrides) -> ProjectIn: + defaults = { + "_id": id, + "title": id[:30], + "authors": "Test Author", + "description": "Test description", + "owner": "google:alice@example.com", + "unique_identifiers": True, + "stats": STATS, + } + defaults.update(overrides) + return ProjectIn(**defaults) + + +async def _insert(id: str, **overrides) -> Project: + project_in = _project_in(id, **overrides) + return await _repo(ADMIN).insert_project(project_in) + + +# --------------------------------------------------------------------------- +# insert_project +# --------------------------------------------------------------------------- + + +class TestInsertProject: + async def test_inserted_project_is_retrievable(self, db): + await _insert("ins-basic") + found = await Project.find_one(Project.id == "ins-basic") + assert found is not None + assert found.id == "ins-basic" + + async def test_duplicate_id_raises_conflict(self, db): + await _insert("ins-dup") + with pytest.raises(ConflictError): + await _insert("ins-dup") + + async def test_default_not_public(self, db): + await _insert("ins-priv") + found = await Project.find_one(Project.id == "ins-priv") + assert found.is_public is False + + async def test_explicit_public(self, db): + await _insert("ins-pub", is_public=True, is_approved=True) + found = await Project.find_one(Project.id == "ins-pub") + assert found.is_public is True + + +# --------------------------------------------------------------------------- +# Authorization scoping (_build_scope) +# --------------------------------------------------------------------------- + + +class TestAuthorizationScope: + async def test_admin_sees_all(self, db): + await _insert("scope-priv", is_public=False) + await _insert("scope-pub", is_public=True, is_approved=True) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + ids = {p.id for p in page.items} + assert "scope-priv" in ids + assert "scope-pub" in ids + + async def test_anonymous_only_sees_public_approved(self, db): + await _insert("anon-priv", is_public=False) + await _insert("anon-pub", is_public=True, is_approved=True) + await _insert("anon-pub-unapproved", is_public=True, is_approved=False) + page = await _repo(ANON).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + ids = {p.id for p in page.items} + assert "anon-pub" in ids + assert "anon-priv" not in ids + assert "anon-pub-unapproved" not in ids + + async def test_authenticated_sees_own_and_public(self, db): + await _insert("auth-alice-priv", owner="google:alice@example.com", is_public=False) + await _insert("auth-bob-priv", owner="google:bob@example.com", is_public=False) + await _insert("auth-pub", is_public=True, is_approved=True) + page = await _repo(ALICE).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + ids = {p.id for p in page.items} + assert "auth-alice-priv" in ids + assert "auth-pub" in ids + assert "auth-bob-priv" not in ids + + +def _noop_filter(): + from mpcontribs_api.domains.projects.models import ProjectFilter + + return ProjectFilter() + + +# --------------------------------------------------------------------------- +# get_project_by_id +# --------------------------------------------------------------------------- + + +class TestGetProjectById: + async def test_returns_project_for_valid_id(self, db): + await _insert("get-by-id") + result = await _repo(ADMIN).get_project_by_id(id="get-by-id", fields=None) + assert result is not None + assert result.id == "get-by-id" + + async def test_returns_none_for_missing_id(self, db): + result = await _repo(ADMIN).get_project_by_id(id="does-not-exist", fields=None) + assert result is None + + async def test_admin_can_get_private_project(self, db): + await _insert("get-priv", is_public=False) + result = await _repo(ADMIN).get_project_by_id(id="get-priv", fields=None) + assert result is not None + + async def test_anon_cannot_get_private_project(self, db): + await _insert("get-priv-anon", is_public=False) + result = await _repo(ANON).get_project_by_id(id="get-priv-anon", fields=None) + assert result is None + + +# --------------------------------------------------------------------------- +# get_projects — id filtering +# +# Regression: Beanie stores the primary key under Mongo's ``_id`` (``id`` is an +# alias), but fastapi-filter keys queries on the raw field name. Without the +# ``id`` -> ``_id`` remap in BaseFilter these filters matched nothing even +# though get_project_by_id (which queries ``_id`` directly) found the document. +# --------------------------------------------------------------------------- + + +class TestGetProjectsIdFilter: + async def test_filter_by_id_matches(self, db): + from mpcontribs_api.domains.projects.models import ProjectFilter + + await _insert("filter-id-hit") + page = await _repo(ADMIN).get_projects( + filter=ProjectFilter(id="filter-id-hit"), pagination=CursorParams(), fields=None + ) + assert {p.id for p in page.items} == {"filter-id-hit"} + + async def test_filter_by_id_in_matches(self, db): + from mpcontribs_api.domains.projects.models import ProjectFilter + + await _insert("filter-id-in-a") + await _insert("filter-id-in-b") + await _insert("filter-id-in-c") + page = await _repo(ADMIN).get_projects( + filter=ProjectFilter(id__in=["filter-id-in-a", "filter-id-in-b"]), + pagination=CursorParams(), + fields=None, + ) + assert {p.id for p in page.items} == {"filter-id-in-a", "filter-id-in-b"} + + async def test_filter_by_id_neq_excludes(self, db): + from mpcontribs_api.domains.projects.models import ProjectFilter + + await _insert("filter-id-neq-keep") + await _insert("filter-id-neq-drop") + page = await _repo(ADMIN).get_projects( + filter=ProjectFilter(id__neq="filter-id-neq-drop"), pagination=CursorParams(), fields=None + ) + ids = {p.id for p in page.items} + assert "filter-id-neq-keep" in ids + assert "filter-id-neq-drop" not in ids + + +# --------------------------------------------------------------------------- +# Field projection +# --------------------------------------------------------------------------- + + +class TestFieldProjection: + async def test_projection_returns_only_requested_fields(self, db): + await _insert("proj-fields", is_public=True, is_approved=True) + fields = ProjectOut.parse_fields(["title"]) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=fields) + assert len(page.items) == 1 + item = page.items[0] + assert item.title == "proj-fields" + # authors was not requested — absent from the projected model entirely + assert not hasattr(item, "authors") + + async def test_no_projection_returns_all_fields(self, db): + await _insert("proj-all", is_public=True, is_approved=True) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + item = page.items[0] + assert item.title is not None + assert item.authors is not None + + +# --------------------------------------------------------------------------- +# Cursor-based pagination +# --------------------------------------------------------------------------- + + +class TestPagination: + async def test_limit_is_respected(self, db): + for i in range(5): + await _insert(f"pag-limit-{i:02d}", is_public=True, is_approved=True) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=3), fields=None) + assert len(page.items) == 3 + + async def test_next_cursor_set_when_more_items(self, db): + for i in range(4): + await _insert(f"pag-cursor-{i:02d}", is_public=True, is_approved=True) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) + assert page.next_cursor is not None + + async def test_next_cursor_none_on_last_page(self, db): + for i in range(3): + await _insert(f"pag-last-{i:02d}", is_public=True, is_approved=True) + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=10), fields=None) + assert page.next_cursor is None + + async def test_cursor_fetches_next_page(self, db): + for i in range(4): + await _insert(f"pag-next-{i:02d}", is_public=True, is_approved=True) + page1 = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) + assert page1.next_cursor is not None + page2 = await _repo(ADMIN).get_projects( + filter=_noop_filter(), pagination=CursorParams(limit=2, cursor=page1.next_cursor), fields=None + ) + ids1 = {p.id for p in page1.items} + ids2 = {p.id for p in page2.items} + assert ids1.isdisjoint(ids2), "pages must not overlap" + + async def test_all_items_covered_across_pages(self, db): + for i in range(5): + await _insert(f"pag-all-{i:02d}", is_public=True, is_approved=True) + all_ids: set[str] = set() + cursor = None + while True: + page = await _repo(ADMIN).get_projects( + filter=_noop_filter(), pagination=CursorParams(limit=2, cursor=cursor), fields=None + ) + all_ids.update(p.id for p in page.items) + cursor = page.next_cursor + if cursor is None: + break + assert all(f"pag-all-{i:02d}" in all_ids for i in range(5)) + + +# --------------------------------------------------------------------------- +# patch_project_by_id +# --------------------------------------------------------------------------- + + +class TestPatchProject: + async def test_updates_single_field(self, db): + await _insert("patch-me") + patch = ProjectPatch(title="Updated Title") + await _repo(ADMIN).patch_project_by_id(id="patch-me", update=patch) + found = await Project.find_one(Project.id == "patch-me") + assert found.title == "Updated Title" + + async def test_unset_fields_not_overwritten(self, db): + await _insert("patch-preserve") + original = await Project.find_one(Project.id == "patch-preserve") + patch = ProjectPatch(title="New Title") + await _repo(ADMIN).patch_project_by_id(id="patch-preserve", update=patch) + found = await Project.find_one(Project.id == "patch-preserve") + assert found.authors == original.authors + + async def test_not_found_raises(self, db): + patch = ProjectPatch(title="Won't work") + with pytest.raises(NotFoundError): + await _repo(ADMIN).patch_project_by_id(id="no-such-id", update=patch) + + async def test_empty_patch_returns_existing(self, db): + await _insert("patch-empty") + result = await _repo(ADMIN).patch_project_by_id(id="patch-empty", update=ProjectPatch()) + assert result.id == "patch-empty" + + +# --------------------------------------------------------------------------- +# delete_project_by_id (soft-delete via DocumentWithSoftDelete) +# --------------------------------------------------------------------------- + + +class TestDeleteProject: + async def test_deleted_project_not_in_default_query(self, db): + await _insert("del-me", is_public=True, is_approved=True) + await _repo(ADMIN).delete_project_by_id(id="del-me") + page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + ids = {p.id for p in page.items} + assert "del-me" not in ids + + async def test_delete_nonexistent_throws_error(self, db): + # delete_project_by_id does find_one().delete() — Error if not found + with pytest.raises(NotFoundError, match="not found"): + await _repo(ADMIN).delete_project_by_id(id="ghost-id") + + +# --------------------------------------------------------------------------- +# upsert_project_by_id +# --------------------------------------------------------------------------- + + +class TestUpsertProject: + async def test_upsert_creates_new_project(self, db): + data = _project_in("upsert-new") + await _repo(ADMIN).upsert_project_by_id(id="upsert-new", data=data) + found = await Project.find_one(Project.id == "upsert-new") + assert found is not None + + async def test_upsert_updates_existing_project(self, db): + await _insert("upsert-existing") + data = _project_in("upsert-existing", title="Replaced Title") + await _repo(ADMIN).upsert_project_by_id(id="upsert-existing", data=data) + found = await Project.find_one(Project.id == "upsert-existing") + assert found.title == "Replaced Title" + + async def test_upsert_uses_path_id_not_body_id(self, db): + data = _project_in("body-id") + await _repo(ADMIN).upsert_project_by_id(id="path-id", data=data) + found = await Project.find_one(Project.id == "path-id") + assert found is not None + + +# --------------------------------------------------------------------------- +# upsert_project_by_id — authorization (owner-or-admin) +# --------------------------------------------------------------------------- + +BOB = User(username="google:bob@example.com", groups=frozenset()) + + +class TestUpsertProjectAuthorization: + async def test_owner_can_overwrite_own_project(self, db): + await _insert("auth-own", owner="google:alice@example.com") + data = _project_in("auth-own", owner="google:alice@example.com", title="Owner Edit") + await _repo(ALICE).upsert_project_by_id(id="auth-own", data=data) + found = await Project.find_one(Project.id == "auth-own") + assert found.title == "Owner Edit" + + async def test_admin_can_overwrite_any_project(self, db): + await _insert("auth-admin", owner="google:alice@example.com") + data = _project_in("auth-admin", owner="google:alice@example.com", title="Admin Edit") + await _repo(ADMIN).upsert_project_by_id(id="auth-admin", data=data) + found = await Project.find_one(Project.id == "auth-admin") + assert found.title == "Admin Edit" + + async def test_non_owner_cannot_overwrite(self, db): + await _insert("auth-other", owner="google:alice@example.com", title="Original") + data = _project_in("auth-other", owner="google:alice@example.com", title="Hijacked") + from mpcontribs_api.exceptions import PermissionError as AppPermissionError + + with pytest.raises(AppPermissionError): + await _repo(BOB).upsert_project_by_id(id="auth-other", data=data) + found = await Project.find_one(Project.id == "auth-other") + assert found.title == "Original" + + async def test_new_project_sets_owner_to_caller(self, db): + # Body owner is someone else; the caller's identity must win. + data = _project_in("auth-newowner", owner="google:alice@example.com") + await _repo(BOB).upsert_project_by_id(id="auth-newowner", data=data) + found = await Project.find_one(Project.id == "auth-newowner") + assert found.owner == "google:bob@example.com" + + async def test_update_preserves_original_owner(self, db): + await _insert("auth-preserve", owner="google:alice@example.com") + # Alice tries to reassign ownership via the body; owner must stay hers. + data = _project_in("auth-preserve", owner="google:bob@example.com", title="Edit") + await _repo(ALICE).upsert_project_by_id(id="auth-preserve", data=data) + found = await Project.find_one(Project.id == "auth-preserve") + assert found.owner == "google:alice@example.com" diff --git a/mpcontribs-api/tests/integration/test_body_size_limit.py b/mpcontribs-api/tests/integration/test_body_size_limit.py new file mode 100644 index 000000000..0e15e289d --- /dev/null +++ b/mpcontribs-api/tests/integration/test_body_size_limit.py @@ -0,0 +1,50 @@ +"""Tests for BodySizeLimitMiddleware — the 413 guard against oversized request bodies.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from mpcontribs_api.exceptions import register_exception_handlers +from mpcontribs_api.middleware import BodySizeLimitMiddleware + +MAX_BYTES = 1024 + + +def _make_app(max_bytes: int = MAX_BYTES) -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_bytes) + + @app.post("/echo") + async def echo(payload: dict) -> dict: + return {"received": len(payload)} + + return app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(_make_app(), raise_server_exceptions=False) + + +class TestBodySizeLimit: + def test_under_limit_passes(self, client: TestClient): + r = client.post("/echo", json={"a": 1}) + assert r.status_code == 200 + + def test_declared_content_length_over_limit_rejected(self, client: TestClient): + # A body whose Content-Length exceeds the ceiling is rejected before it's read. + big = {"blob": "x" * (MAX_BYTES * 2)} + r = client.post("/echo", json=big) + assert r.status_code == 413 + assert r.json()["error"]["code"] == "payload_too_large" + + def test_streamed_body_over_limit_rejected(self, client: TestClient): + # No Content-Length (chunked): the middleware accumulates and rejects via raised AppError, + # which the standard handler renders as a uniform 413. + def gen(): + yield b"x" * (MAX_BYTES * 2) + + r = client.post("/echo", content=gen()) + assert r.status_code == 413 + assert r.json()["error"]["code"] == "payload_too_large" diff --git a/mpcontribs-api/tests/integration/test_bulk_limits.py b/mpcontribs-api/tests/integration/test_bulk_limits.py new file mode 100644 index 000000000..47021ac44 --- /dev/null +++ b/mpcontribs-api/tests/integration/test_bulk_limits.py @@ -0,0 +1,78 @@ +"""Tests for the per-request bulk-write count guard and the GET /limits contract endpoint.""" + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.config import get_settings +from mpcontribs_api.domains._shared.bulk import BulkWriteSummary +from mpcontribs_api.domains.contributions.dependencies import get_contribution_service +from tests.integration.conftest import AUTHED_HEADERS + + +def _valid_contribution_body(**overrides) -> dict: + body = { + "_id": str(PydanticObjectId()), + "project": "test-project", + "identifier": "mp-1234", + "formula": "Fe2O3", + "data": {"band_gap": 2.1}, + } + body.update(overrides) + return body + + +@pytest.fixture +def contribution_service(test_app, mock_contribution_service): + test_app.dependency_overrides[get_contribution_service] = lambda: mock_contribution_service + yield mock_contribution_service + test_app.dependency_overrides.pop(get_contribution_service, None) + + +@pytest.fixture(autouse=True) +def _authenticate(client): + client.headers.update(AUTHED_HEADERS) + + +class TestBulkWriteLimit: + @pytest.fixture(autouse=True) + def _small_limit(self, monkeypatch): + # Shrink the limit so the test doesn't need to build 1000+ bodies. + monkeypatch.setattr(get_settings().mongo, "bulk_write_limit", 2) + + def test_over_limit_post_returns_422(self, client, contribution_service): + body = [_valid_contribution_body() for _ in range(3)] + r = client.post("/api/v1/contributions", json=body) + assert r.status_code == 422 + assert r.json()["error"]["code"] == "validation_error" + contribution_service.insert_contributions.assert_not_called() + + def test_at_limit_post_passes(self, client, contribution_service): + contribution_service.insert_contributions.return_value = BulkWriteSummary(total=2, succeeded=[], failed=[]) + body = [_valid_contribution_body() for _ in range(2)] + r = client.post("/api/v1/contributions", json=body) + assert r.status_code == 200 + contribution_service.insert_contributions.assert_called_once() + + def test_over_limit_put_returns_422(self, client, contribution_service): + body = [_valid_contribution_body() for _ in range(3)] + r = client.put("/api/v1/contributions", json=body) + assert r.status_code == 422 + contribution_service.upsert_contributions.assert_not_called() + + +class TestLimitsEndpoint: + def test_limits_reports_configured_values(self, client): + mongo = get_settings().mongo + r = client.get("/api/v1/limits") + assert r.status_code == 200 + assert r.json() == { + "max_request_bytes": mongo.max_request_bytes, + "bulk_write_limit": mongo.bulk_write_limit, + "max_components_per_contribution": mongo.max_components_per_contribution, + "component_insert_chunk_size": mongo.component_insert_chunk_size, + } + + def test_limits_is_public(self, client): + # No auth headers: still readable (public metadata). + client.headers.clear() + assert client.get("/api/v1/limits").status_code == 200 diff --git a/mpcontribs-api/tests/integration/test_component_routes.py b/mpcontribs-api/tests/integration/test_component_routes.py new file mode 100644 index 000000000..c1b49cd8b --- /dev/null +++ b/mpcontribs-api/tests/integration/test_component_routes.py @@ -0,0 +1,333 @@ +from unittest.mock import AsyncMock + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.domains._shared.models import ComponentDeleteResponse +from mpcontribs_api.domains.attachments.dependencies import get_attachment_service +from mpcontribs_api.domains.structures.dependencies import get_structure_service +from mpcontribs_api.domains.structures.models import StructureOut +from mpcontribs_api.domains.tables.dependencies import get_table_service +from mpcontribs_api.domains.tables.models import TableOut +from mpcontribs_api.pagination import Page +from tests.integration.conftest import AUTHED_HEADERS, FORCE_ANON_HEADERS + +# --------------------------------------------------------------------------- +# Fixtures: every component endpoint routes through the unified ComponentService, +# so each domain has a single mock service override. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _authenticate(client): + """Mutating component endpoints now require an authenticated caller. + + These route tests exercise mutations, so default the shared client to an + authenticated identity. Anonymous-rejection is covered explicitly by the + *RequireAuth tests, which force anonymity with FORCE_ANON_HEADERS. + """ + client.headers.update(AUTHED_HEADERS) + + +@pytest.fixture +def structure_service(test_app): + mock = AsyncMock() + test_app.dependency_overrides[get_structure_service] = lambda: mock + yield mock + test_app.dependency_overrides.pop(get_structure_service, None) + + +@pytest.fixture +def table_service(test_app): + mock = AsyncMock() + test_app.dependency_overrides[get_table_service] = lambda: mock + yield mock + test_app.dependency_overrides.pop(get_table_service, None) + + +@pytest.fixture +def attachment_service(test_app): + mock = AsyncMock() + test_app.dependency_overrides[get_attachment_service] = lambda: mock + yield mock + test_app.dependency_overrides.pop(get_attachment_service, None) + + +SAMPLE_STRUCTURE = StructureOut(name="Fe2O3.cif", md5="a" * 32) +SAMPLE_TABLE = TableOut(name="bandgaps", md5="b" * 32) + + +# =========================================================================== +# STRUCTURES +# =========================================================================== + + +class TestStructuresList: + def test_empty_page_returns_200(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + assert client.get("/api/v1/structures").status_code == 200 + + def test_page_shape(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[SAMPLE_STRUCTURE], next_cursor="c") + body = client.get("/api/v1/structures").json() + assert "items" in body + assert body["next_cursor"] == "c" + + def test_service_called_with_pagination(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/structures?limit=5") + kwargs = structure_service.get_many.call_args.kwargs + assert kwargs["pagination"].limit == 5 + + def test_invalid_fields_returns_422(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + assert client.get("/api/v1/structures?_fields=not_a_field").status_code == 422 + + def test_valid_fields_forwarded(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/structures?_fields=name") + # parse_fields always includes the id identity field. + assert structure_service.get_many.call_args.kwargs["fields"] == frozenset({"id", "name"}) + + def test_content_fields_are_selectable(self, client, structure_service): + # Regression for #4: structure content must be reachable via _fields (on the Out model). + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/structures?_fields=lattice&_fields=sites&_fields=charge&_fields=cif") + assert r.status_code == 200 + assert structure_service.get_many.call_args.kwargs["fields"] == frozenset( + {"id", "lattice", "sites", "charge", "cif"} + ) + + +class TestStructuresDelete: + def test_batch_delete_returns_200(self, client, structure_service): + structure_service.delete.return_value = ComponentDeleteResponse(num_deleted=3) + r = client.delete("/api/v1/structures") + assert r.status_code == 200 + assert r.json() == {"num_deleted": 3, "num_skipped": 0, "referenced_ids": []} + + def test_service_delete_called(self, client, structure_service): + structure_service.delete.return_value = ComponentDeleteResponse(num_deleted=0) + client.delete("/api/v1/structures") + structure_service.delete.assert_awaited_once() + + +class TestStructuresInsert: + def test_post_route_exists(self, client, structure_service): + # Empty body -> handler invoked; service returns a summary-shaped object. + structure_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + r = client.post("/api/v1/structures", json=[]) + assert r.status_code != 404 + + def test_post_forwards_to_service(self, client, structure_service): + structure_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + client.post("/api/v1/structures", json=[]) + structure_service.insert.assert_awaited_once() + + +class TestStructuresByIdRouting: + def test_get_by_id_conventional_path(self, client, structure_service): + structure_service.get_by_id.return_value = SAMPLE_STRUCTURE + assert client.get(f"/api/v1/structures/{PydanticObjectId()}").status_code == 200 + + def test_delete_by_id_conventional_path(self, client, structure_service): + structure_service.delete_by_id.return_value = ComponentDeleteResponse(num_deleted=1) + assert client.delete(f"/api/v1/structures/{PydanticObjectId()}").status_code == 200 + + def test_patch_by_id_conventional_path(self, client, structure_service): + structure_service.patch_by_id.return_value = SAMPLE_STRUCTURE + r = client.patch(f"/api/v1/structures/{PydanticObjectId()}", json={"name": "renamed"}) + assert r.status_code == 200 + + def test_download_conventional_path(self, client, structure_service): + structure_service.download.return_value = iter([b"x"]) + assert client.get("/api/v1/structures/download/gz?format=csv").status_code == 200 + + +# =========================================================================== +# TABLES +# =========================================================================== + + +class TestTablesList: + def test_empty_page_returns_200(self, client, table_service): + table_service.get_many.return_value = Page(items=[], next_cursor=None) + assert client.get("/api/v1/tables").status_code == 200 + + def test_page_shape(self, client, table_service): + table_service.get_many.return_value = Page(items=[SAMPLE_TABLE], next_cursor=None) + assert "items" in client.get("/api/v1/tables").json() + + def test_invalid_fields_returns_422(self, client, table_service): + table_service.get_many.return_value = Page(items=[], next_cursor=None) + assert client.get("/api/v1/tables?_fields=nope").status_code == 422 + + def test_default_fields_accepted(self, client, table_service): + table_service.get_many.return_value = Page(items=[], next_cursor=None) + assert client.get("/api/v1/tables").status_code == 200 + + +class TestTablesDelete: + def test_batch_delete_returns_200(self, client, table_service): + table_service.delete.return_value = ComponentDeleteResponse(num_deleted=2) + assert client.delete("/api/v1/tables").json() == { + "num_deleted": 2, + "num_skipped": 0, + "referenced_ids": [], + } + + +class TestTablesInsert: + def test_post_forwards_to_service(self, client, table_service): + table_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + client.post("/api/v1/tables", json=[]) + table_service.insert.assert_awaited_once() + + +class TestTablesByIdRouting: + def test_get_by_id_conventional_path(self, client, table_service): + table_service.get_by_id.return_value = SAMPLE_TABLE + assert client.get(f"/api/v1/tables/{PydanticObjectId()}").status_code == 200 + + def test_delete_by_id_conventional_path(self, client, table_service): + table_service.delete_by_id.return_value = ComponentDeleteResponse(num_deleted=1) + assert client.delete(f"/api/v1/tables/{PydanticObjectId()}").status_code == 200 + + def test_patch_by_id_conventional_path(self, client, table_service): + table_service.patch_by_id.return_value = SAMPLE_TABLE + r = client.patch(f"/api/v1/tables/{PydanticObjectId()}", json={"name": "x"}) + assert r.status_code == 200 + + +# =========================================================================== +# ATTACHMENTS +# =========================================================================== + + +class TestAttachmentsRouterWiring: + def test_list_calls_attachment_service(self, client, attachment_service): + attachment_service.get_many.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/attachments") + assert r.status_code == 200 + attachment_service.get_many.assert_awaited_once() + + def test_get_by_id_calls_attachment_service(self, client, attachment_service): + attachment_service.get_by_id.return_value = None + client.get(f"/api/v1/attachments/{PydanticObjectId()}") + attachment_service.get_by_id.assert_awaited_once() + + def test_delete_by_id_calls_attachment_service(self, client, attachment_service): + attachment_service.delete_by_id.return_value = ComponentDeleteResponse(num_deleted=1) + client.delete(f"/api/v1/attachments/{PydanticObjectId()}") + attachment_service.delete_by_id.assert_awaited_once() + + def test_batch_delete_calls_attachment_service(self, client, attachment_service): + attachment_service.delete.return_value = ComponentDeleteResponse(num_deleted=0) + client.delete("/api/v1/attachments") + attachment_service.delete.assert_awaited_once() + + +# =========================================================================== +# Component downloads: /{resource}/download/{short_mime} +# +# Parametrised over the three component resources so download behavior is held +# to the same contract everywhere. Each entry is +# (url_prefix, service_fixture_name, expected_stem). +# =========================================================================== + +_DOWNLOAD_CASES = [ + ("structures", "structure_service", "structures"), + ("tables", "table_service", "tables"), + ("attachments", "attachment_service", "attachments"), +] + + +@pytest.fixture +def download_target(request): + """Resolve a (prefix, service_fixture, stem) case into a wired service mock.""" + prefix, service_fixture, stem = request.param + service = request.getfixturevalue(service_fixture) + service.download.return_value = iter([b"x"]) + return prefix, service, stem + + +@pytest.mark.parametrize("download_target", _DOWNLOAD_CASES, indirect=True) +class TestComponentDownloads: + def test_csv_returns_200(self, client, download_target): + prefix, *_ = download_target + assert client.get(f"/api/v1/{prefix}/download/gz?format=csv").status_code == 200 + + def test_jsonl_returns_200(self, client, download_target): + prefix, *_ = download_target + assert client.get(f"/api/v1/{prefix}/download/gz?format=jsonl").status_code == 200 + + def test_body_is_streamed_bytes(self, client, download_target): + prefix, service, _ = download_target + service.download.return_value = iter([b"ab", b"cd"]) + assert client.get(f"/api/v1/{prefix}/download/gz?format=jsonl").content == b"abcd" + + def test_format_is_required(self, client, download_target): + # Component download routes give `format` no default, unlike contributions. + prefix, *_ = download_target + assert client.get(f"/api/v1/{prefix}/download/gz").status_code == 422 + + def test_invalid_short_mime_returns_422(self, client, download_target): + prefix, *_ = download_target + assert client.get(f"/api/v1/{prefix}/download/zip?format=jsonl").status_code == 422 + + def test_invalid_format_returns_422(self, client, download_target): + prefix, *_ = download_target + assert client.get(f"/api/v1/{prefix}/download/gz?format=xml").status_code == 422 + + def test_format_forwarded_to_service(self, client, download_target): + prefix, service, _ = download_target + client.get(f"/api/v1/{prefix}/download/gz?format=csv") + assert service.download.call_args.kwargs["format"] == "csv" + + def test_csv_filename_uses_csv_extension(self, client, download_target): + """A CSV download is named *.csv.gz, matching the requested format.""" + prefix, *_ = download_target + cd = client.get(f"/api/v1/{prefix}/download/gz?format=csv").headers["content-disposition"] + assert ".csv.gz" in cd + + +# =========================================================================== +# Authentication enforcement: component mutations require an authenticated user +# =========================================================================== + + +class TestComponentMutationsRequireAuth: + def test_structures_post_anon_401(self, client, structure_service): + r = client.post("/api/v1/structures", json=[], headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + structure_service.insert.assert_not_called() + + def test_structures_delete_anon_401(self, client, structure_service): + r = client.delete("/api/v1/structures", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + structure_service.delete.assert_not_called() + + def test_structure_delete_by_id_anon_401(self, client, structure_service): + r = client.delete(f"/api/v1/structures/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + structure_service.delete_by_id.assert_not_called() + + def test_structure_patch_by_id_anon_401(self, client, structure_service): + r = client.patch(f"/api/v1/structures/{PydanticObjectId()}", json={"name": "x"}, headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + structure_service.patch_by_id.assert_not_called() + + def test_tables_delete_anon_401(self, client, table_service): + r = client.delete("/api/v1/tables", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + table_service.delete.assert_not_called() + + def test_attachment_delete_by_id_anon_401(self, client, attachment_service): + r = client.delete(f"/api/v1/attachments/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + attachment_service.delete_by_id.assert_not_called() + + def test_structures_get_still_open_to_anon(self, client, structure_service): + structure_service.get_many.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/structures", headers=FORCE_ANON_HEADERS) + assert r.status_code == 200 diff --git a/mpcontribs-api/tests/integration/test_contributions.py b/mpcontribs-api/tests/integration/test_contributions.py new file mode 100644 index 000000000..7f61ac4bb --- /dev/null +++ b/mpcontribs-api/tests/integration/test_contributions.py @@ -0,0 +1,122 @@ +import pytest + +from mpcontribs_api.domains.contributions.dependencies import get_scoped_contributions +from mpcontribs_api.domains.contributions.models import ContributionOut +from mpcontribs_api.pagination import Page +from tests.integration.conftest import ANON_HEADERS, AUTHED_HEADERS + +# --------------------------------------------------------------------------- +# Fixture: inject mock repo for each test +# --------------------------------------------------------------------------- + + +@pytest.fixture +def contribution_repo(test_app, mock_contribution_repo): + test_app.dependency_overrides[get_scoped_contributions] = lambda: mock_contribution_repo + yield mock_contribution_repo + test_app.dependency_overrides.pop(get_scoped_contributions, None) + + +SAMPLE_CONTRIBUTION = ContributionOut( + project="mp-project", + identifier="mp-001", + formula="Fe2O3", + is_public=True, + data={"band_gap": 2.1}, +) + + +# --------------------------------------------------------------------------- +# GET /api/v1/contributions +# --------------------------------------------------------------------------- + + +class TestListContributions: + def test_empty_page_returns_200(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/contributions", headers=AUTHED_HEADERS) + assert r.status_code == 200 + + def test_response_has_page_shape(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + body = client.get("/api/v1/contributions", headers=AUTHED_HEADERS).json() + assert "items" in body + assert "next_cursor" in body + + def test_items_in_response(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[SAMPLE_CONTRIBUTION], next_cursor=None) + body = client.get("/api/v1/contributions", headers=AUTHED_HEADERS).json() + assert len(body["items"]) == 1 + + def test_repo_called_with_pagination(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/contributions", params={"limit": 10}, headers=AUTHED_HEADERS) + _, kwargs = contribution_repo.get_contributions.call_args + assert kwargs["pagination"].limit == 10 + + def test_fields_forwarded(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/contributions", params={"_fields": ["formula"]}, headers=AUTHED_HEADERS) + _, kwargs = contribution_repo.get_contributions.call_args + assert kwargs["fields"] is not None + assert "formula" in kwargs["fields"] + + def test_invalid_fields_returns_422(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/contributions", params={"_fields": "bad_field"}, headers=AUTHED_HEADERS) + assert r.status_code == 422 + + def test_anonymous_can_list(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/contributions", headers=ANON_HEADERS) + assert r.status_code == 200 + + def test_filter_param_forwarded_to_repo(self, client, contribution_repo): + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/contributions", params={"formula": "Fe2O3"}, headers=AUTHED_HEADERS) + contribution_repo.get_contributions.assert_called_once() + + +# --------------------------------------------------------------------------- +# DELETE /api/v1/contributions (batch) +# --------------------------------------------------------------------------- + + +class TestDeleteContributions: + def test_batch_delete_returns_200(self, client, contribution_repo): + contribution_repo.delete_contributions.return_value = None + r = client.delete("/api/v1/contributions", headers=AUTHED_HEADERS) + assert r.status_code == 200 + + def test_repo_delete_called(self, client, contribution_repo): + contribution_repo.delete_contributions.return_value = None + client.delete("/api/v1/contributions", headers=AUTHED_HEADERS) + contribution_repo.delete_contributions.assert_called_once() + + def test_filter_forwarded_to_repo(self, client, contribution_repo): + contribution_repo.delete_contributions.return_value = None + client.delete("/api/v1/contributions", params={"is_public": "true"}, headers=AUTHED_HEADERS) + _, kwargs = contribution_repo.delete_contributions.call_args + assert kwargs["filter"] is not None + + +# --------------------------------------------------------------------------- +# Stub endpoints — verify they exist and wire to the repo +# --------------------------------------------------------------------------- + + +class TestStubEndpoints: + """These endpoints are stubs in the repo but the routes must be wired.""" + + def test_post_contributions_route_exists(self, client, contribution_repo): + contribution_repo.insert_contributions.return_value = None + r = client.post("/api/v1/contributions", json=[], headers=AUTHED_HEADERS) + # Should reach the route (not 404/405) even if the handler is a stub + assert r.status_code != 404 + assert r.status_code != 405 + + def test_put_contributions_route_exists(self, client, contribution_repo): + contribution_repo.upsert_contributions.return_value = None + r = client.put("/api/v1/contributions", json=[], headers=AUTHED_HEADERS) + assert r.status_code != 404 + assert r.status_code != 405 diff --git a/mpcontribs-api/tests/integration/test_contributions_routes.py b/mpcontribs-api/tests/integration/test_contributions_routes.py new file mode 100644 index 000000000..36adad598 --- /dev/null +++ b/mpcontribs-api/tests/integration/test_contributions_routes.py @@ -0,0 +1,302 @@ +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.domains._shared.bulk import BulkDeleteSummary, BulkWriteSummary +from mpcontribs_api.domains.contributions.dependencies import ( + get_contribution_service, + get_scoped_contributions, +) +from mpcontribs_api.domains.contributions.models import ContributionOut +from mpcontribs_api.exceptions import NotFoundError +from tests.integration.conftest import AUTHED_HEADERS, FORCE_ANON_HEADERS + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _authenticate(client): + """Mutating contribution endpoints now require an authenticated caller. + + Default the shared client to an authenticated identity so the existing + mutation tests still exercise the handler; anonymous-rejection is covered + explicitly by TestContributionMutationsRequireAuth via FORCE_ANON_HEADERS. + """ + client.headers.update(AUTHED_HEADERS) + + +@pytest.fixture +def contribution_repo(test_app, mock_contribution_repo): + test_app.dependency_overrides[get_scoped_contributions] = lambda: mock_contribution_repo + yield mock_contribution_repo + test_app.dependency_overrides.pop(get_scoped_contributions, None) + + +@pytest.fixture +def contribution_service(test_app, mock_contribution_service): + test_app.dependency_overrides[get_contribution_service] = lambda: mock_contribution_service + yield mock_contribution_service + test_app.dependency_overrides.pop(get_contribution_service, None) + + +def _valid_contribution_body(**overrides) -> dict: + # ContributionIn inherits a required _id from BaseDocumentWithInput, so a + # client-supplied object id is part of the create contract (mirrors the + # service unit tests, which always pass _id). + body = { + "_id": str(PydanticObjectId()), + "project": "test-project", + "identifier": "mp-1234", + "formula": "Fe2O3", + "data": {"band_gap": 2.1}, + } + body.update(overrides) + return body + + +SAMPLE_OUT = ContributionOut(project="p", identifier="mp-1", formula="Fe2O3") + + +# =========================================================================== +# POST /contributions (bulk insert via service) +# =========================================================================== + + +class TestInsertContributions: + def test_empty_list_returns_200(self, client, contribution_service): + contribution_service.insert_contributions.return_value = BulkWriteSummary( + total=0, succeeded=[], failed=[] + ) + r = client.post("/api/v1/contributions", json=[]) + assert r.status_code == 200 + + def test_response_has_summary_shape(self, client, contribution_service): + contribution_service.insert_contributions.return_value = BulkWriteSummary( + total=0, succeeded=[], failed=[] + ) + body = client.post("/api/v1/contributions", json=[]).json() + assert set(body) == {"total", "succeeded", "failed"} + + def test_service_receives_parsed_contributions(self, client, contribution_service): + contribution_service.insert_contributions.return_value = BulkWriteSummary( + total=1, succeeded=[], failed=[] + ) + client.post("/api/v1/contributions", json=[_valid_contribution_body()]) + contributions = contribution_service.insert_contributions.call_args.kwargs["contributions"] + assert len(contributions) == 1 + assert contributions[0].project == "test-project" + + def test_malformed_body_returns_422(self, client, contribution_service): + # Missing required 'formula'. + r = client.post( + "/api/v1/contributions", + json=[{"_id": str(PydanticObjectId()), "project": "p", "identifier": "mp-1"}], + ) + assert r.status_code == 422 + contribution_service.insert_contributions.assert_not_called() + + def test_non_list_body_returns_422(self, client, contribution_service): + r = client.post("/api/v1/contributions", json=_valid_contribution_body()) + assert r.status_code == 422 + + +# =========================================================================== +# PUT /contributions (bulk upsert via service) +# =========================================================================== + + +class TestUpsertContributions: + def test_empty_list_returns_200(self, client, contribution_service): + contribution_service.upsert_contributions.return_value = BulkWriteSummary(total=0, succeeded=[], failed=[]) + r = client.put("/api/v1/contributions", json=[]) + assert r.status_code == 200 + assert set(r.json()) == {"total", "succeeded", "failed"} + + def test_service_receives_parsed_contributions(self, client, contribution_service): + contribution_service.upsert_contributions.return_value = BulkWriteSummary(total=1, succeeded=[], failed=[]) + client.put("/api/v1/contributions", json=[_valid_contribution_body()]) + contributions = contribution_service.upsert_contributions.call_args.kwargs["contributions"] + assert contributions[0].identifier == "mp-1234" + + def test_malformed_body_returns_422(self, client, contribution_service): + r = client.put( + "/api/v1/contributions", + json=[{"_id": str(PydanticObjectId()), "project": "p"}], + ) + assert r.status_code == 422 + contribution_service.upsert_contributions.assert_not_called() + + +# =========================================================================== +# Single-resource routes (RED: glued path params) +# =========================================================================== + + +class TestContributionByIdRouting: + """RED: routes mount as /contributions{id} not /contributions/{id}.""" + + def test_get_by_id_conventional_path(self, client, contribution_repo): + contribution_repo.get_contribution_by_id.return_value = SAMPLE_OUT + assert client.get(f"/api/v1/contributions/{PydanticObjectId()}").status_code == 200 + + def test_patch_by_id_conventional_path(self, client, contribution_repo): + contribution_repo.patch_contribution_by_id.return_value = SAMPLE_OUT + r = client.patch(f"/api/v1/contributions/{PydanticObjectId()}", json={"formula": "H2O"}) + assert r.status_code == 200 + + def test_put_by_id_conventional_path(self, client, contribution_repo): + contribution_repo.upsert_contribution_by_id.return_value = SAMPLE_OUT + r = client.put(f"/api/v1/contributions/{PydanticObjectId()}", json=_valid_contribution_body()) + assert r.status_code == 200 + + def test_delete_by_id_conventional_path(self, client, contribution_service): + contribution_service.delete_contributions.return_value = BulkDeleteSummary( + num_deleted=1, num_children_deleted=0 + ) + assert client.delete(f"/api/v1/contributions/{PydanticObjectId()}").status_code == 200 + + def test_download_route_conventional_path(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + assert client.get("/api/v1/contributions/download/gz").status_code == 200 + + +# =========================================================================== +# Single-resource behavior (independent of the routing bug, via current paths) +# =========================================================================== + + +class TestDeleteContributionByIdWiring: + def test_delete_delegates_to_service(self, client, contribution_service): + contribution_service.delete_contributions.return_value = BulkDeleteSummary( + num_deleted=1, num_children_deleted=2 + ) + oid = PydanticObjectId() + # NOTE: glued path is intentional here — see module docstring. + r = client.delete(f"/api/v1/contributions/{oid}") + assert r.status_code == 200 + contribution_service.delete_contributions.assert_awaited_once() + + def test_delete_builds_filter_from_path_id(self, client, contribution_service): + contribution_service.delete_contributions.return_value = BulkDeleteSummary( + num_deleted=1, num_children_deleted=0 + ) + oid = PydanticObjectId() + client.delete(f"/api/v1/contributions/{oid}") + passed_filter = contribution_service.delete_contributions.call_args.args[0] + assert passed_filter.id == oid + + +# =========================================================================== +# GET /contributions/download/{short_mime} +# =========================================================================== + + +class TestDownloadContributions: + def test_default_format_jsonl_returns_200(self, client, contribution_repo): + # The contributions route gives `format` a default of JSONL, so it works + # with the param omitted (component routes require it — see test_component_routes). + contribution_repo.download_contributions.return_value = iter([b"x"]) + assert client.get("/api/v1/contributions/download/gz").status_code == 200 + + def test_csv_format_returns_200(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + assert client.get("/api/v1/contributions/download/gz?format=csv").status_code == 200 + + def test_body_is_streamed_bytes(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"abc", b"def"]) + assert client.get("/api/v1/contributions/download/gz").content == b"abcdef" + + def test_invalid_short_mime_returns_422(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + # Only 'gz' is a valid ShortMimeFormat. + assert client.get("/api/v1/contributions/download/zip").status_code == 422 + + def test_invalid_format_returns_422(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + assert client.get("/api/v1/contributions/download/gz?format=xml").status_code == 422 + + def test_format_forwarded_to_repo(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + client.get("/api/v1/contributions/download/gz?format=csv") + assert contribution_repo.download_contributions.call_args.kwargs["format"] == "csv" + + def test_fields_parsed_and_forwarded(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + client.get("/api/v1/contributions/download/gz?_fields=project") + forwarded = contribution_repo.download_contributions.call_args.kwargs["fields"] + assert "project" in forwarded + + def test_invalid_fields_returns_422(self, client, contribution_repo): + contribution_repo.download_contributions.return_value = iter([b"x"]) + assert client.get("/api/v1/contributions/download/gz?_fields=not_a_field").status_code == 422 + + def test_filename_names_the_contributions_resource(self, client, contribution_repo): + """The attachment filename references the contributions resource.""" + contribution_repo.download_contributions.return_value = iter([b"x"]) + cd = client.get("/api/v1/contributions/download/gz").headers["content-disposition"] + assert "contributions" in cd + + def test_csv_filename_uses_csv_extension(self, client, contribution_repo): + """A CSV download is named *.csv.gz, matching the requested format.""" + contribution_repo.download_contributions.return_value = iter([b"x"]) + cd = client.get("/api/v1/contributions/download/gz?format=csv").headers["content-disposition"] + assert ".csv.gz" in cd + + def test_repo_error_surfaces_as_uniform_json(self, client, contribution_repo): + # An AppError raised while the repo builds the download surfaces through the + # registered exception handler as the uniform error envelope (not a 500 traceback). + contribution_repo.download_contributions.side_effect = NotFoundError("nothing to download") + r = client.get("/api/v1/contributions/download/gz") + assert r.status_code == 404 + assert r.json()["error"]["code"] == "not_found" + + +# --------------------------------------------------------------------------- +# Authentication enforcement: contribution mutations require an authenticated user +# --------------------------------------------------------------------------- + + +class TestContributionMutationsRequireAuth: + def test_post_anon_401(self, client, contribution_service): + r = client.post("/api/v1/contributions", json=[], headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + assert r.json()["error"]["code"] == "authentication_error" + contribution_service.insert_contributions.assert_not_called() + + def test_put_collection_anon_401(self, client, contribution_service): + r = client.put("/api/v1/contributions", json=[], headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + contribution_service.upsert_contributions.assert_not_called() + + def test_delete_collection_anon_401(self, client, contribution_repo): + r = client.delete("/api/v1/contributions", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + contribution_repo.delete_contributions.assert_not_called() + + def test_delete_by_id_anon_401(self, client, contribution_service): + r = client.delete(f"/api/v1/contributions/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + + def test_put_by_id_anon_401(self, client, contribution_repo): + r = client.put( + f"/api/v1/contributions/{PydanticObjectId()}", + json=_valid_contribution_body(), + headers=FORCE_ANON_HEADERS, + ) + assert r.status_code == 401 + contribution_repo.upsert_contribution_by_id.assert_not_called() + + def test_patch_by_id_anon_401(self, client, contribution_repo): + r = client.patch( + f"/api/v1/contributions/{PydanticObjectId()}", json={"formula": "H2O"}, headers=FORCE_ANON_HEADERS + ) + assert r.status_code == 401 + contribution_repo.patch_contribution_by_id.assert_not_called() + + def test_get_collection_still_open_to_anon(self, client, contribution_repo): + from mpcontribs_api.pagination import Page + + contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/contributions", headers=FORCE_ANON_HEADERS) + assert r.status_code == 200 diff --git a/mpcontribs-api/tests/integration/test_error_handlers.py b/mpcontribs-api/tests/integration/test_error_handlers.py new file mode 100644 index 000000000..a092cec2a --- /dev/null +++ b/mpcontribs-api/tests/integration/test_error_handlers.py @@ -0,0 +1,177 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from mpcontribs_api.exceptions import ( + AuthenticationError, + ConflictError, + NotFoundError, + PermissionError, + ValidationError, +) +from tests.integration.conftest import AUTHED_HEADERS, make_test_app + +# --------------------------------------------------------------------------- +# Helper: mount a /probe route that raises a specific AppError +# --------------------------------------------------------------------------- + + +def _app_with_probe(exc: Exception) -> FastAPI: + """Create a test app with a GET /probe route that raises `exc`.""" + app = make_test_app() + + @app.get("/probe") + async def _probe(): + raise exc + + return app + + +def _client(app: FastAPI) -> TestClient: + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# Unknown route — Starlette HTTPException(404) → {"error": {"code": "http_error"}} +# --------------------------------------------------------------------------- + + +class TestUnknownRoute: + def test_status_404(self, client): + r = client.get("/no/such/route", headers=AUTHED_HEADERS) + assert r.status_code == 404 + + def test_error_envelope(self, client): + r = client.get("/no/such/route", headers=AUTHED_HEADERS) + body = r.json() + assert "error" in body + assert body["error"]["code"] == "http_error" + assert "message" in body["error"] + + +# --------------------------------------------------------------------------- +# Request body validation — FastAPI RequestValidationError → 422 +# --------------------------------------------------------------------------- + + +class TestRequestValidation: + def test_missing_required_body_field(self, client): + # PUT /api/v1/projects/{id} requires a ProjectIn body + r = client.put( + "/api/v1/projects/my-proj", + json={"title": "Missing required fields"}, + headers=AUTHED_HEADERS, + ) + assert r.status_code == 422 + + def test_validation_error_envelope(self, client): + r = client.put( + "/api/v1/projects/my-proj", + json={"title": "Missing required fields"}, + headers=AUTHED_HEADERS, + ) + body = r.json() + assert body["error"]["code"] == "validation_error" + assert "errors" in body["error"]["detail"] + + def test_non_json_body(self, client): + r = client.put( + "/api/v1/projects/my-proj", + content=b"not json at all", + headers={**AUTHED_HEADERS, "content-type": "application/json"}, + ) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# AppError subclasses — verify status codes and envelope shapes +# --------------------------------------------------------------------------- + + +class TestNotFoundError: + def setup_method(self): + self._app = _app_with_probe(NotFoundError("project 'foo' not found")) + self._client = _client(self._app) + + def test_status_404(self): + assert self._client.get("/probe").status_code == 404 + + def test_error_code(self): + assert self._client.get("/probe").json()["error"]["code"] == "not_found" + + def test_message(self): + assert "foo" in self._client.get("/probe").json()["error"]["message"] + + +class TestConflictError: + def setup_method(self): + self._app = _app_with_probe(ConflictError("duplicate id")) + self._client = _client(self._app) + + def test_status_409(self): + assert self._client.get("/probe").status_code == 409 + + def test_error_code(self): + assert self._client.get("/probe").json()["error"]["code"] == "conflict" + + +class TestValidationErrorHandler: + def setup_method(self): + self._app = _app_with_probe(ValidationError("bad field value")) + self._client = _client(self._app) + + def test_status_422(self): + assert self._client.get("/probe").status_code == 422 + + def test_error_code(self): + assert self._client.get("/probe").json()["error"]["code"] == "validation_error" + + +class TestPermissionErrorHandler: + def setup_method(self): + self._app = _app_with_probe(PermissionError(required_role="admin")) + self._client = _client(self._app) + + def test_status_403(self): + assert self._client.get("/probe").status_code == 403 + + def test_error_code(self): + assert self._client.get("/probe").json()["error"]["code"] == "permission_denied" + + +class TestAuthenticationErrorHandler: + def setup_method(self): + self._app = _app_with_probe(AuthenticationError("login required")) + self._client = _client(self._app) + + def test_status_401(self): + assert self._client.get("/probe").status_code == 401 + + def test_error_code(self): + assert self._client.get("/probe").json()["error"]["code"] == "authentication_error" + + +# --------------------------------------------------------------------------- +# Envelope shape invariants — all error responses share the same top-level key +# --------------------------------------------------------------------------- + + +class TestEnvelopeShape: + @pytest.mark.parametrize( + "exc, expected_status", + [ + (NotFoundError("x"), 404), + (ConflictError("x"), 409), + (ValidationError("x"), 422), + (PermissionError(), 403), + (AuthenticationError("x"), 401), + ], + ) + def test_always_has_error_key(self, exc, expected_status): + app = _app_with_probe(exc) + r = _client(app).get("/probe") + assert r.status_code == expected_status + body = r.json() + assert "error" in body + assert "code" in body["error"] + assert "message" in body["error"] diff --git a/mpcontribs-api/tests/integration/test_healthcheck.py b/mpcontribs-api/tests/integration/test_healthcheck.py new file mode 100644 index 000000000..f98ca854f --- /dev/null +++ b/mpcontribs-api/tests/integration/test_healthcheck.py @@ -0,0 +1,124 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from botocore.exceptions import ClientError +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from mpcontribs_api.dependencies import get_db, get_s3 +from mpcontribs_api.domains.healthcheck.router import router as healthcheck_router +from mpcontribs_api.exceptions import register_exception_handlers + + +def _make_db(ping_ok: bool) -> MagicMock: + """Build a mock AsyncDatabase whose client.admin.command is awaitable.""" + db = MagicMock(name="db") + if ping_ok: + db.client.admin.command = AsyncMock(return_value={"ok": 1}) + else: + db.client.admin.command = AsyncMock(side_effect=ConnectionError("mongo down")) + return db + + +def _make_s3(head_ok: bool) -> MagicMock: + """Build a mock S3 client whose head_bucket is awaitable.""" + s3 = MagicMock(name="s3") + if head_ok: + s3.head_bucket = AsyncMock(return_value={}) + else: + s3.head_bucket = AsyncMock( + side_effect=ClientError({"Error": {"Code": "503", "Message": "s3 down"}}, "HeadBucket") + ) + return s3 + + +@pytest.fixture +def health_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + app.include_router(healthcheck_router, prefix="/healthcheck") + return app + + +def _client(app: FastAPI, db: MagicMock, s3: MagicMock | None = None) -> TestClient: + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[get_s3] = lambda: s3 if s3 is not None else _make_s3(head_ok=True) + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# Healthy path +# --------------------------------------------------------------------------- + + +class TestHealthcheckHealthy: + def test_returns_200(self, health_app): + r = _client(health_app, _make_db(ping_ok=True), _make_s3(head_ok=True)).get("/healthcheck") + assert r.status_code == 200 + + def test_body_reports_healthy(self, health_app): + r = _client(health_app, _make_db(ping_ok=True), _make_s3(head_ok=True)).get("/healthcheck") + assert r.json() == {"version": "0.0.0-dev", "status": "healthy", "mongo": "ok", "s3": "ok"} + + def test_pings_mongo(self, health_app): + db = _make_db(ping_ok=True) + _client(health_app, db, _make_s3(head_ok=True)).get("/healthcheck") + db.client.admin.command.assert_awaited_once_with("ping") + + def test_probes_s3(self, health_app): + s3 = _make_s3(head_ok=True) + _client(health_app, _make_db(ping_ok=True), s3).get("/healthcheck") + s3.head_bucket.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Unhealthy path (Mongo unreachable) +# --------------------------------------------------------------------------- + + +class TestHealthcheckMongoUnhealthy: + def test_returns_503(self, health_app): + r = _client(health_app, _make_db(ping_ok=False)).get("/healthcheck") + assert r.status_code == 503 + + def test_body_reports_unreachable(self, health_app): + # The StarletteHTTPException handler reshapes the response into the + # standard error envelope, stringifying the detail dict into `message`. + r = _client(health_app, _make_db(ping_ok=False)).get("/healthcheck") + message = r.json()["error"]["message"] + assert "unhealthy" in message + assert "unreachable" in message + + def test_ping_failure_does_not_leak_exception_text(self, health_app): + # The raised HTTPException carries a controlled detail dict, not the + # underlying "mongo down" ConnectionError message. + r = _client(health_app, _make_db(ping_ok=False)).get("/healthcheck") + assert "mongo down" not in r.text + + def test_s3_not_probed_when_mongo_down(self, health_app): + # Mongo is checked first; a failure short-circuits before S3 is touched. + s3 = _make_s3(head_ok=True) + _client(health_app, _make_db(ping_ok=False), s3).get("/healthcheck") + s3.head_bucket.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Unhealthy path (S3 unreachable) +# --------------------------------------------------------------------------- + + +class TestHealthcheckS3Unhealthy: + def test_returns_503(self, health_app): + r = _client(health_app, _make_db(ping_ok=True), _make_s3(head_ok=False)).get("/healthcheck") + assert r.status_code == 503 + + def test_body_reports_unreachable(self, health_app): + r = _client(health_app, _make_db(ping_ok=True), _make_s3(head_ok=False)).get("/healthcheck") + message = r.json()["error"]["message"] + assert "unhealthy" in message + assert "unreachable" in message + + def test_s3_failure_does_not_leak_exception_text(self, health_app): + # The controlled detail dict is returned, not the underlying boto error text. + r = _client(health_app, _make_db(ping_ok=True), _make_s3(head_ok=False)).get("/healthcheck") + assert "s3 down" not in r.text diff --git a/mpcontribs-api/tests/integration/test_ingest_jobs_routes.py b/mpcontribs-api/tests/integration/test_ingest_jobs_routes.py new file mode 100644 index 000000000..10465867b --- /dev/null +++ b/mpcontribs-api/tests/integration/test_ingest_jobs_routes.py @@ -0,0 +1,89 @@ +"""Tests for the async bulk-ingestion scaffold routes (submit / status). + +The worker is a stub, so these exercise only the route surface: 202 on submit, a persisted +pending job, idempotency replay, auth, and status lookup (found / not-found / bad id). +""" + +from unittest.mock import AsyncMock + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.domains.ingest_jobs.dependencies import get_ingest_job_repository +from mpcontribs_api.domains.ingest_jobs.models import IngestJob +from tests.integration.conftest import AUTHED_HEADERS, FORCE_ANON_HEADERS + +BULK_URL = "/api/v1/contributions/bulk" + + +def _job(**overrides) -> IngestJob: + fields = { + "id": PydanticObjectId(), + "owner": "google:alice@example.com", + "status": "pending", + "source": None, + "idempotency_key": None, + } + fields.update(overrides) + return IngestJob(**fields) + + +@pytest.fixture +def ingest_repo(test_app): + repo = AsyncMock() + test_app.dependency_overrides[get_ingest_job_repository] = lambda: repo + yield repo + test_app.dependency_overrides.pop(get_ingest_job_repository, None) + + +@pytest.fixture(autouse=True) +def _authenticate(client): + client.headers.update(AUTHED_HEADERS) + + +class TestSubmitBulkIngest: + def test_submit_returns_202_and_pending_job(self, client, ingest_repo): + job = _job() + ingest_repo.create_job.return_value = job + r = client.post(BULK_URL, json={"source": None}) + assert r.status_code == 202 + body = r.json() + assert body["id"] == str(job.id) + assert body["status"] == "pending" + ingest_repo.create_job.assert_awaited_once() + + def test_submit_requires_auth(self, client, test_app): + # No repo override here: the repository dependency runs require_user, which rejects anon. + r = client.post(BULK_URL, json={"source": None}, headers=FORCE_ANON_HEADERS) + assert r.status_code == 401 + + def test_idempotency_key_replays_existing_job(self, client, ingest_repo): + existing = _job(idempotency_key="abc123") + ingest_repo.find_by_idempotency_key.return_value = existing + r = client.post(BULK_URL, json={"source": None}, headers={"Idempotency-Key": "abc123"}) + assert r.status_code == 202 + assert r.json()["id"] == str(existing.id) + ingest_repo.find_by_idempotency_key.assert_awaited_once_with("abc123") + ingest_repo.create_job.assert_not_called() + + +class TestGetBulkIngestJob: + def test_get_existing_job(self, client, ingest_repo): + job = _job(status="running", total=10, succeeded=4) + ingest_repo.get_job.return_value = job + r = client.get(f"{BULK_URL}/{job.id}") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "running" + assert body["total"] == 10 + assert body["succeeded"] == 4 + + def test_unknown_job_returns_404(self, client, ingest_repo): + ingest_repo.get_job.return_value = None + r = client.get(f"{BULK_URL}/{PydanticObjectId()}") + assert r.status_code == 404 + + def test_invalid_job_id_returns_422(self, client, ingest_repo): + r = client.get(f"{BULK_URL}/not-an-object-id") + assert r.status_code == 422 + ingest_repo.get_job.assert_not_called() diff --git a/mpcontribs-api/tests/integration/test_projects.py b/mpcontribs-api/tests/integration/test_projects.py new file mode 100644 index 000000000..0f9885792 --- /dev/null +++ b/mpcontribs-api/tests/integration/test_projects.py @@ -0,0 +1,296 @@ +import pytest + +from mpcontribs_api.domains.projects.dependencies import get_scoped_projects +from mpcontribs_api.domains.projects.models import ProjectOut, Stats +from mpcontribs_api.exceptions import ConflictError, NotFoundError +from mpcontribs_api.pagination import Page +from tests.integration.conftest import ANON_HEADERS, AUTHED_HEADERS + +# --------------------------------------------------------------------------- +# Shared sample data +# --------------------------------------------------------------------------- + +SAMPLE_STATS = Stats(columns=1, contributions=5, tables=0, structures=0, attachments=0, size=128.0) + +SAMPLE_PROJECT = ProjectOut.model_validate( + { + "_id": "mp-sample", + "title": "Sample Project", + "authors": "Alice, Bob", + "description": "A sample", + "is_public": True, + "is_approved": True, + "stats": SAMPLE_STATS, + } +) + + +# --------------------------------------------------------------------------- +# Fixture: inject mock repo into the test_app for the duration of each test +# --------------------------------------------------------------------------- + + +@pytest.fixture +def project_repo(test_app, mock_project_repo): + test_app.dependency_overrides[get_scoped_projects] = lambda: mock_project_repo + yield mock_project_repo + test_app.dependency_overrides.pop(get_scoped_projects, None) + + +# --------------------------------------------------------------------------- +# GET /api/v1/projects +# --------------------------------------------------------------------------- + + +class TestListProjects: + def test_empty_page_returns_200(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/projects", headers=AUTHED_HEADERS) + assert r.status_code == 200 + + def test_response_has_items_and_cursor(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() + assert "items" in body + assert "next_cursor" in body + + def test_items_returned_in_response(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=None) + body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() + assert len(body["items"]) == 1 + + def test_next_cursor_set_when_more_pages(self, client, project_repo): + from mpcontribs_api.pagination import encode_cursor + + cursor = encode_cursor("mp-sample") + project_repo.get_projects.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=cursor) + body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() + assert body["next_cursor"] == cursor + + def test_repo_get_project_called(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/projects", headers=AUTHED_HEADERS) + project_repo.get_projects.assert_called_once() + + def test_anonymous_user_reaches_route(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/projects", headers=ANON_HEADERS) + assert r.status_code == 200 + + def test_invalid_fields_param_returns_422(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + r = client.get("/api/v1/projects", params={"_fields": "nonexistent_field"}, headers=AUTHED_HEADERS) + assert r.status_code == 422 + + def test_limit_param_forwarded(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/projects", params={"limit": 5}, headers=AUTHED_HEADERS) + _, kwargs = project_repo.get_projects.call_args + assert kwargs["pagination"].limit == 5 + + def test_limit_above_max_returns_422(self, client, project_repo): + r = client.get("/api/v1/projects", params={"limit": 999}, headers=AUTHED_HEADERS) + assert r.status_code == 422 + + def test_valid_fields_param_forwarded(self, client, project_repo): + project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + client.get("/api/v1/projects", params=[("_fields", "title"), ("_fields", "authors")], headers=AUTHED_HEADERS) + _, kwargs = project_repo.get_projects.call_args + assert kwargs["fields"] is not None + assert "title" in kwargs["fields"] + + +# --------------------------------------------------------------------------- +# GET /api/v1/projects/{id} +# --------------------------------------------------------------------------- + + +class TestGetProjectById: + def test_found_returns_200(self, client, project_repo): + project_repo.get_project_by_id.return_value = SAMPLE_PROJECT + r = client.get("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS) + assert r.status_code == 200 + + def test_response_contains_project_data(self, client, project_repo): + project_repo.get_project_by_id.return_value = SAMPLE_PROJECT + body = client.get("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS).json() + assert body["id"] == "mp-sample" + assert body["title"] == "Sample Project" + + def test_not_found_returns_404(self, client, project_repo): + project_repo.get_project_by_id.side_effect = NotFoundError("project not found") + r = client.get("/api/v1/projects/nonexistent", headers=AUTHED_HEADERS) + assert r.status_code == 404 + + def test_not_found_error_code(self, client, project_repo): + project_repo.get_project_by_id.side_effect = NotFoundError("project not found") + body = client.get("/api/v1/projects/nonexistent", headers=AUTHED_HEADERS).json() + assert body["error"]["code"] == "not_found" + + def test_id_forwarded_to_repo(self, client, project_repo): + project_repo.get_project_by_id.return_value = SAMPLE_PROJECT + client.get("/api/v1/projects/my-specific-id", headers=AUTHED_HEADERS) + _, kwargs = project_repo.get_project_by_id.call_args + assert kwargs["id"] == "my-specific-id" + + def test_fields_param_forwarded(self, client, project_repo): + project_repo.get_project_by_id.return_value = SAMPLE_PROJECT + client.get("/api/v1/projects/mp-sample", params={"_fields": "title"}, headers=AUTHED_HEADERS) + _, kwargs = project_repo.get_project_by_id.call_args + assert kwargs["fields"] is not None + assert "title" in kwargs["fields"] + + def test_no_fields_param_uses_default_fields(self, client, project_repo): + project_repo.get_project_by_id.return_value = SAMPLE_PROJECT + client.get("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS) + _, kwargs = project_repo.get_project_by_id.call_args + assert kwargs["fields"] is not None + assert "title" in kwargs["fields"] + + +# --------------------------------------------------------------------------- +# PATCH /api/v1/projects/{id} +# --------------------------------------------------------------------------- + + +class TestPatchProject: + def test_valid_patch_returns_200(self, client, project_repo): + project_repo.patch_project_by_id.return_value = SAMPLE_PROJECT + r = client.patch( + "/api/v1/projects/mp-sample", + json={"title": "Updated Title"}, + headers=AUTHED_HEADERS, + ) + assert r.status_code == 200 + + def test_patch_response_is_project_out(self, client, project_repo): + updated = ProjectOut(id="mp-sample", title="Updated Title") + project_repo.patch_project_by_id.return_value = updated + body = client.patch( + "/api/v1/projects/mp-sample", + json={"title": "Updated Title"}, + headers=AUTHED_HEADERS, + ).json() + assert body["title"] == "Updated Title" + + def test_not_found_returns_404(self, client, project_repo): + project_repo.patch_project_by_id.side_effect = NotFoundError("not found") + r = client.patch( + "/api/v1/projects/missing", + json={"title": "x" * 5}, + headers=AUTHED_HEADERS, + ) + assert r.status_code == 404 + + def test_invalid_title_too_short_returns_422(self, client, project_repo): + r = client.patch( + "/api/v1/projects/mp-sample", + json={"title": "ab"}, + headers=AUTHED_HEADERS, + ) + assert r.status_code == 422 + + def test_id_and_update_forwarded_to_repo(self, client, project_repo): + project_repo.patch_project_by_id.return_value = SAMPLE_PROJECT + client.patch( + "/api/v1/projects/mp-sample", + json={"title": "New Name"}, + headers=AUTHED_HEADERS, + ) + _, kwargs = project_repo.patch_project_by_id.call_args + assert kwargs["id"] == "mp-sample" + assert kwargs["update"].title == "New Name" + + +# --------------------------------------------------------------------------- +# DELETE /api/v1/projects/{id} +# --------------------------------------------------------------------------- + + +class TestDeleteProject: + def test_delete_returns_204(self, client, project_repo): + project_repo.delete_project_by_id.return_value = None + r = client.delete("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS) + assert r.status_code == 204 + + def test_delete_response_has_no_body(self, client, project_repo): + project_repo.delete_project_by_id.return_value = None + r = client.delete("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS) + assert r.content == b"" + + def test_id_forwarded_to_repo(self, client, project_repo): + project_repo.delete_project_by_id.return_value = None + client.delete("/api/v1/projects/mp-sample", headers=AUTHED_HEADERS) + _, kwargs = project_repo.delete_project_by_id.call_args + assert kwargs["id"] == "mp-sample" + + +# --------------------------------------------------------------------------- +# PUT /api/v1/projects/{id} +# --------------------------------------------------------------------------- + + +class TestUpsertProject: + def _valid_body(self, **overrides): + body = { + "_id": "mp-sample", + "title": "Test Project", + "authors": "Alice", + "description": "A project", + "owner": "google:alice@example.com", + "unique_identifiers": True, + "stats": {"columns": 0, "contributions": 0, "tables": 0, "structures": 0, "attachments": 0, "size": 0.0}, + } + body.update(overrides) + return body + + def test_valid_upsert_returns_200(self, client, project_repo): + project_repo.upsert_project_by_id.return_value = SAMPLE_PROJECT + r = client.put("/api/v1/projects/mp-sample", json=self._valid_body(), headers=AUTHED_HEADERS) + assert r.status_code == 200 + + def test_conflict_returns_409(self, client, project_repo): + project_repo.upsert_project_by_id.side_effect = ConflictError("already exists") + r = client.put("/api/v1/projects/mp-sample", json=self._valid_body(), headers=AUTHED_HEADERS) + assert r.status_code == 409 + + def test_missing_required_field_returns_422(self, client, project_repo): + body = self._valid_body() + del body["title"] + r = client.put("/api/v1/projects/mp-sample", json=body, headers=AUTHED_HEADERS) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# Authentication enforcement: mutations require an authenticated user +# --------------------------------------------------------------------------- + + +class TestProjectMutationsRequireAuth: + def _body(self): + return { + "_id": "mp-sample", + "title": "Test Project", + "authors": "Alice", + "description": "A project", + "owner": "google:alice@example.com", + "unique_identifiers": True, + "stats": {"columns": 0, "contributions": 0, "tables": 0, "structures": 0, "attachments": 0, "size": 0.0}, + } + + def test_anonymous_put_returns_401(self, client, project_repo): + project_repo.upsert_project_by_id.return_value = SAMPLE_PROJECT + r = client.put("/api/v1/projects/mp-sample", json=self._body(), headers=ANON_HEADERS) + assert r.status_code == 401 + assert r.json()["error"]["code"] == "authentication_error" + project_repo.upsert_project_by_id.assert_not_called() + + def test_anonymous_patch_returns_401(self, client, project_repo): + r = client.patch("/api/v1/projects/mp-sample", json={"title": "Updated Title"}, headers=ANON_HEADERS) + assert r.status_code == 401 + project_repo.patch_project_by_id.assert_not_called() + + def test_anonymous_delete_returns_401(self, client, project_repo): + r = client.delete("/api/v1/projects/mp-sample", headers=ANON_HEADERS) + assert r.status_code == 401 + project_repo.delete_project_by_id.assert_not_called() diff --git a/mpcontribs-api/tests/integration/test_redirects.py b/mpcontribs-api/tests/integration/test_redirects.py new file mode 100644 index 000000000..31f18f646 --- /dev/null +++ b/mpcontribs-api/tests/integration/test_redirects.py @@ -0,0 +1,143 @@ +"""Tests for the legacy-compatibility redirect/deprecation router. + +The legacy (Flask) API lived at the service root (e.g. ``/contributions/``); +the rewrite serves everything under ``/api/v1``. The redirects router mounted +at the root either 308-redirects to the new location (preserving method, body +and query string) or returns ``410 Gone`` for endpoints with no counterpart. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from tests.integration.conftest import make_test_app + +# 308 keeps the method/body intact; assert it explicitly so a future change to +# 301/302 (which downgrade POST/PUT to GET) is caught. +PERMANENT_REDIRECT = 308 +GONE = 410 + + +@pytest.fixture(scope="module") +def app() -> FastAPI: + return make_test_app() + + +@pytest.fixture +def client(app: FastAPI): + """Client that does NOT auto-follow redirects, so we can assert on 3xx.""" + with TestClient(app, raise_server_exceptions=False, follow_redirects=False) as c: + yield c + + +# --------------------------------------------------------------------------- +# Redirects: legacy endpoints with a direct /api/v1 counterpart +# --------------------------------------------------------------------------- + +# (method, legacy_path, expected /api/v1 location) +REDIRECT_CASES = [ + # contributions + ("GET", "/contributions/", "/api/v1/contributions"), + ("POST", "/contributions/", "/api/v1/contributions"), + ("PUT", "/contributions/", "/api/v1/contributions"), + ("DELETE", "/contributions/", "/api/v1/contributions"), + ("GET", "/contributions/abc123/", "/api/v1/contributions/abc123"), + ("PUT", "/contributions/abc123/", "/api/v1/contributions/abc123"), + ("DELETE", "/contributions/abc123/", "/api/v1/contributions/abc123"), + ("GET", "/contributions/download/gz/", "/api/v1/contributions/download/gz"), + # projects (GET collection + item verbs) + ("GET", "/projects/", "/api/v1/projects"), + ("GET", "/projects/my-proj/", "/api/v1/projects/my-proj"), + ("PUT", "/projects/my-proj/", "/api/v1/projects/my-proj"), + ("DELETE", "/projects/my-proj/", "/api/v1/projects/my-proj"), + # read-only components + ("GET", "/structures/", "/api/v1/structures"), + ("GET", "/structures/sid/", "/api/v1/structures/sid"), + ("GET", "/structures/download/gz/", "/api/v1/structures/download/gz"), + ("GET", "/tables/", "/api/v1/tables"), + ("GET", "/tables/tid/", "/api/v1/tables/tid"), + ("GET", "/tables/download/gz/", "/api/v1/tables/download/gz"), + ("GET", "/attachments/", "/api/v1/attachments"), + ("GET", "/attachments/aid/", "/api/v1/attachments/aid"), + ("GET", "/attachments/download/gz/", "/api/v1/attachments/download/gz"), +] + + +class TestRedirects: + @pytest.mark.parametrize("method, legacy_path, new_path", REDIRECT_CASES) + def test_status_is_permanent_redirect(self, client, method, legacy_path, new_path): + r = client.request(method, legacy_path) + assert r.status_code == PERMANENT_REDIRECT + + @pytest.mark.parametrize("method, legacy_path, new_path", REDIRECT_CASES) + def test_location_points_to_v1(self, client, method, legacy_path, new_path): + r = client.request(method, legacy_path) + assert r.headers["location"] == new_path + + def test_query_string_is_preserved(self, client): + r = client.get("/contributions/?project=foo&_limit=5") + assert r.headers["location"] == "/api/v1/contributions?project=foo&_limit=5" + + def test_query_string_preserved_on_item(self, client): + r = client.get("/structures/sid/?_fields=id,label") + assert r.headers["location"] == "/api/v1/structures/sid?_fields=id,label" + + def test_download_query_preserved(self, client): + r = client.get("/tables/download/gz/?format=csv") + assert r.headers["location"] == "/api/v1/tables/download/gz?format=csv" + + def test_no_query_string_has_no_trailing_question_mark(self, client): + r = client.get("/projects/") + assert r.headers["location"] == "/api/v1/projects" + assert "?" not in r.headers["location"] + + +# --------------------------------------------------------------------------- +# Deprecations: legacy endpoints with no counterpart → 410 Gone +# --------------------------------------------------------------------------- + +# (method, legacy_path) +GONE_CASES = [ + # search helpers (Atlas $search) were not ported + ("GET", "/contributions/search"), + ("GET", "/projects/search"), + # project creation has no POST endpoint in the new API + ("POST", "/projects/"), + # email-driven application approval links + ("GET", "/projects/applications/sometoken"), + ("GET", "/projects/applications/sometoken/approve"), + ("GET", "/projects/applications/sometoken/deny"), + # the whole notebooks resource was dropped + ("GET", "/notebooks/"), + ("GET", "/notebooks/nbid/"), + ("GET", "/notebooks/build"), + ("GET", "/notebooks/result"), + ("GET", "/notebooks/result/job-1"), +] + + +class TestDeprecated: + @pytest.mark.parametrize("method, path", GONE_CASES) + def test_status_is_gone(self, client, method, path): + assert client.request(method, path).status_code == GONE + + @pytest.mark.parametrize("method, path", GONE_CASES) + def test_error_envelope(self, client, method, path): + body = client.request(method, path).json() + assert body["error"]["code"] == "endpoint_deprecated" + assert body["error"]["message"] + + @pytest.mark.parametrize("method, path", GONE_CASES) + def test_deprecation_header(self, client, method, path): + assert client.request(method, path).headers["deprecation"] == "true" + + def test_post_projects_points_at_put_replacement(self, client): + r = client.post("/projects/") + assert r.status_code == GONE + body = r.json() + assert body["error"]["detail"]["replacement"] == "/api/v1/projects/{id}" + + def test_deprecated_responses_are_not_redirects(self, client): + # A deprecated endpoint must never carry a Location header. + r = client.get("/notebooks/build") + assert "location" not in r.headers diff --git a/mpcontribs-api/tests/unit/__init__.py b/mpcontribs-api/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mpcontribs-api/tests/unit/conftest.py b/mpcontribs-api/tests/unit/conftest.py new file mode 100644 index 000000000..d730154a0 --- /dev/null +++ b/mpcontribs-api/tests/unit/conftest.py @@ -0,0 +1,11 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True, scope="session") +def _mock_beanie_collection(): + import beanie + + with patch.object(beanie.Document, "get_pymongo_collection", return_value=MagicMock()): + yield diff --git a/mpcontribs-api/tests/unit/domains/__init__.py b/mpcontribs-api/tests/unit/domains/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mpcontribs-api/tests/unit/domains/test_attachments_models.py b/mpcontribs-api/tests/unit/domains/test_attachments_models.py new file mode 100644 index 000000000..f5b17a736 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_attachments_models.py @@ -0,0 +1,161 @@ +import pytest +from beanie import PydanticObjectId +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.domains.attachments.models import ( + Attachment, + AttachmentFilter, + AttachmentIn, + AttachmentOut, + AttachmentPatch, +) +from mpcontribs_api.exceptions import ValidationError as AppValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _payload(**overrides) -> dict: + payload = { + "_id": PydanticObjectId(), + "name": "spectrum.json.gz", + "md5": "c" * 32, + "mime": "application/gzip", + "content": 1, + } + payload.update(overrides) + return payload + + +# --------------------------------------------------------------------------- +# Attachment / AttachmentIn +# --------------------------------------------------------------------------- + + +class TestAttachment: + def test_valid_construction(self): + attachment = Attachment(**_payload()) + assert attachment.name == "spectrum.json.gz" + assert attachment.content == 1 + + def test_collection_name(self): + assert Attachment.Settings.name == "attachments" + + def test_name_requires_extension(self): + with pytest.raises(AppValidationError): + Attachment(**_payload(name="noextension")) + + def test_md5_is_computed_not_taken_from_input(self): + # A client-supplied md5 is overwritten by the content-derived hash. + attachment = Attachment(**_payload(md5="c" * 32)) + assert attachment.md5 != "c" * 32 + assert len(attachment.md5) == 32 + + def test_same_content_same_md5(self): + assert Attachment(**_payload()).md5 == Attachment(**_payload()).md5 + + def test_different_content_different_md5(self): + assert Attachment(**_payload(content=1)).md5 != Attachment(**_payload(content=2)).md5 + + def test_invalid_mime_raises(self): + with pytest.raises(AppValidationError): + Attachment(**_payload(mime="text/plain")) + + def test_missing_content_raises(self): + payload = _payload() + del payload["content"] + with pytest.raises(PydanticValidationError): + Attachment(**payload) + + def test_attachment_in_has_no_id_or_md5(self): + assert "md5" not in AttachmentIn.model_fields + assert "id" not in AttachmentIn.model_fields + + def test_attachment_in_validates_name_extension(self): + with pytest.raises(AppValidationError): + AttachmentIn(name="noextension", mime="application/gzip", content=1) + + def test_from_input_assigns_id_and_computes_md5(self): + doc = Attachment.from_input(AttachmentIn(name="s.gz", mime="application/gzip", content=1)) + assert doc.id is not None + assert len(doc.md5) == 32 + + +# --------------------------------------------------------------------------- +# AttachmentOut +# --------------------------------------------------------------------------- + + +class TestAttachmentOut: + def test_all_fields_optional(self): + out = AttachmentOut() + assert out.id is None + assert out.name is None + assert out.md5 is None + assert out.mime is None + + def test_partial_population(self): + out = AttachmentOut(name="a.gz") + assert out.name == "a.gz" + assert out.md5 is None + + def test_populates_id_from_mongo_alias(self): + oid = PydanticObjectId() + out = AttachmentOut.model_validate({"_id": oid, "md5": "d" * 32}) + assert out.id == oid + + def test_validators_still_apply_when_value_given(self): + with pytest.raises(AppValidationError): + AttachmentOut(mime="text/plain") + + +# --------------------------------------------------------------------------- +# AttachmentPatch +# --------------------------------------------------------------------------- + + +class TestAttachmentPatch: + def test_all_fields_optional(self): + patch = AttachmentPatch() + assert patch.name is None + assert patch.mime is None + + def test_partial_patch_excludes_unset(self): + patch = AttachmentPatch(name="renamed.gz") + assert patch.model_dump(exclude_unset=True) == {"name": "renamed.gz"} + + def test_invalid_name_raises(self): + with pytest.raises(AppValidationError): + AttachmentPatch(name="noextension") + + def test_invalid_mime_raises(self): + with pytest.raises(AppValidationError): + AttachmentPatch(mime="bogus") + + +# --------------------------------------------------------------------------- +# AttachmentFilter +# --------------------------------------------------------------------------- + + +class TestAttachmentFilter: + def test_empty_filter(self): + filter = AttachmentFilter() + assert filter.id is None + assert filter.md5 is None + assert filter.name__ilike is None + + def test_constants_bind_attachment_model(self): + assert AttachmentFilter.Constants.model is Attachment + + def test_md5_value_validated(self): + assert AttachmentFilter(md5="E" * 32).md5 == "e" * 32 + + def test_invalid_md5_raises(self): + with pytest.raises(AppValidationError): + AttachmentFilter(md5="nothex") + + def test_id_in_accepts_object_ids(self): + oids = [PydanticObjectId(), PydanticObjectId()] + assert AttachmentFilter(id__in=oids).id__in == oids diff --git a/mpcontribs-api/tests/unit/domains/test_component_service.py b/mpcontribs-api/tests/unit/domains/test_component_service.py new file mode 100644 index 000000000..53f86ce17 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_component_service.py @@ -0,0 +1,224 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.domains._shared.models import ComponentDeleteResponse, DeleteResponse +from mpcontribs_api.domains._shared.service import ComponentService +from mpcontribs_api.domains.attachments.models import AttachmentFilter +from mpcontribs_api.exceptions import NotFoundError + +pytestmark = pytest.mark.asyncio + + +def _oid() -> PydanticObjectId: + return PydanticObjectId() + + +def _make_service( + *, + candidate_ids: list[PydanticObjectId], + reachable: set[PydanticObjectId], + referenced: set[PydanticObjectId], +) -> tuple[ComponentService, AsyncMock, AsyncMock]: + """Build a ComponentService over mocked component + contribution repos. + + ``referenced_component_ids`` returns ``reachable`` for scoped checks (access gate) and + ``referenced`` for unscoped checks (global integrity), keyed off the ``scoped`` kwarg. + """ + components = AsyncMock(name="components") + components.list_ids = AsyncMock(return_value=candidate_ids) + components.delete_by_ids = AsyncMock(side_effect=lambda ids: DeleteResponse(num_deleted=len(ids))) + components.delete_by_id = AsyncMock(return_value=DeleteResponse(num_deleted=1)) + components._convert_object_id = MagicMock(side_effect=lambda s: PydanticObjectId(s)) + components._not_found = MagicMock(return_value="not found") + + contributions = AsyncMock(name="contributions") + + async def _referenced(ref_field, ids, *, scoped): + pool = reachable if scoped else referenced + return {i for i in ids if i in pool} + + contributions.referenced_component_ids = AsyncMock(side_effect=_referenced) + + service = ComponentService(components, contributions, ref_field="attachments") + return service, components, contributions + + +# --------------------------------------------------------------------------- +# delete(filter) +# --------------------------------------------------------------------------- + + +async def test_delete_reachable_and_unreferenced_deletes_all(): + a, b = _oid(), _oid() + svc, components, contributions = _make_service( + candidate_ids=[a, b], reachable={a, b}, referenced=set() + ) + + result = await svc.delete(AttachmentFilter()) + + assert isinstance(result, ComponentDeleteResponse) + assert result.num_deleted == 2 + assert result.num_skipped == 0 + assert result.referenced_ids == [] + components.delete_by_ids.assert_awaited_once() + assert set(components.delete_by_ids.await_args.args[0]) == {a, b} + + +async def test_delete_skips_globally_referenced(): + a, b = _oid(), _oid() + svc, components, _ = _make_service(candidate_ids=[a, b], reachable={a, b}, referenced={b}) + + result = await svc.delete(AttachmentFilter()) + + assert result.num_deleted == 1 + assert result.num_skipped == 1 + assert result.referenced_ids == [b] + assert components.delete_by_ids.await_args.args[0] == [a] + + +async def test_delete_not_reachable_deletes_nothing(): + a = _oid() + svc, components, contributions = _make_service(candidate_ids=[a], reachable=set(), referenced={a}) + + result = await svc.delete(AttachmentFilter()) + + assert result.num_deleted == 0 + assert result.num_skipped == 0 + components.delete_by_ids.assert_not_awaited() + # global check is skipped once the access gate yields nothing + assert contributions.referenced_component_ids.await_count == 1 + assert contributions.referenced_component_ids.await_args.kwargs["scoped"] is True + + +async def test_delete_empty_candidate_set(): + svc, components, _ = _make_service(candidate_ids=[], reachable=set(), referenced=set()) + + result = await svc.delete(AttachmentFilter()) + + assert result.num_deleted == 0 + components.delete_by_ids.assert_not_awaited() + + +async def test_delete_checks_scoped_before_global(): + a = _oid() + svc, _, contributions = _make_service(candidate_ids=[a], reachable={a}, referenced=set()) + + await svc.delete(AttachmentFilter()) + + scoped_flags = [c.kwargs["scoped"] for c in contributions.referenced_component_ids.await_args_list] + assert scoped_flags == [True, False] + + +# --------------------------------------------------------------------------- +# delete_by_id(id) +# --------------------------------------------------------------------------- + + +async def test_delete_by_id_not_reachable_raises_not_found(): + oid = _oid() + svc, _, _ = _make_service(candidate_ids=[], reachable=set(), referenced=set()) + + with pytest.raises(NotFoundError): + await svc.delete_by_id(str(oid)) + + +async def test_delete_by_id_referenced_is_skipped(): + oid = _oid() + svc, components, _ = _make_service(candidate_ids=[], reachable={oid}, referenced={oid}) + + result = await svc.delete_by_id(str(oid)) + + assert result.num_deleted == 0 + assert result.num_skipped == 1 + assert result.referenced_ids == [oid] + components.delete_by_id.assert_not_awaited() + + +async def test_delete_by_id_reachable_and_unreferenced_deletes(): + oid = _oid() + svc, components, _ = _make_service(candidate_ids=[], reachable={oid}, referenced=set()) + + result = await svc.delete_by_id(str(oid)) + + assert result.num_deleted == 1 + assert result.num_skipped == 0 + components.delete_by_id.assert_awaited_once_with(oid) + + +# --------------------------------------------------------------------------- +# Read gating: get_by_id / get_many / patch_by_id are reachability-scoped +# --------------------------------------------------------------------------- + + +def _make_read_service(*, reachable: set[PydanticObjectId]) -> tuple[ComponentService, AsyncMock, AsyncMock]: + """ComponentService whose contribution repo reports `reachable` ids as in-scope.""" + components = AsyncMock(name="components") + components._convert_object_id = MagicMock(side_effect=lambda s: PydanticObjectId(s)) + components._not_found = MagicMock(return_value="not found") + + contributions = AsyncMock(name="contributions") + + async def _referenced(ref_field, ids, *, scoped): + return {i for i in ids if i in reachable} if scoped else set() + + contributions.referenced_component_ids = AsyncMock(side_effect=_referenced) + contributions.list_referenced_component_ids = AsyncMock(return_value=reachable) + + service = ComponentService(components, contributions, ref_field="attachments") + return service, components, contributions + + +async def test_get_by_id_unreachable_returns_none_without_fetch(): + oid = _oid() + svc, components, _ = _make_read_service(reachable=set()) + + result = await svc.get_by_id(str(oid), fields=None) + + assert result is None + components.get_component_by_id.assert_not_awaited() + + +async def test_get_by_id_reachable_fetches_component(): + oid = _oid() + svc, components, _ = _make_read_service(reachable={oid}) + components.get_component_by_id = AsyncMock(return_value="the-component") + + result = await svc.get_by_id(str(oid), fields=None) + + assert result == "the-component" + components.get_component_by_id.assert_awaited_once() + + +async def test_get_many_restricts_to_reachable_ids(): + a, b = _oid(), _oid() + svc, components, contributions = _make_read_service(reachable={a, b}) + components.get_many = AsyncMock(return_value="page") + + await svc.get_many(filter=AttachmentFilter(), pagination=None, fields=None) + + contributions.list_referenced_component_ids.assert_awaited_once() + assert contributions.list_referenced_component_ids.await_args.kwargs["scoped"] is True + restrict = components.get_many.await_args.kwargs["restrict_ids"] + assert set(restrict) == {a, b} + + +async def test_patch_by_id_unreachable_raises_not_found(): + oid = _oid() + svc, components, _ = _make_read_service(reachable=set()) + + with pytest.raises(NotFoundError): + await svc.patch_by_id(str(oid), update=MagicMock()) + components.patch_component_by_id.assert_not_awaited() + + +async def test_patch_by_id_reachable_patches(): + oid = _oid() + svc, components, _ = _make_read_service(reachable={oid}) + components.patch_component_by_id = AsyncMock(return_value="patched") + + result = await svc.patch_by_id(str(oid), update=MagicMock()) + + assert result == "patched" + components.patch_component_by_id.assert_awaited_once() diff --git a/mpcontribs-api/tests/unit/domains/test_contribution_service.py b/mpcontribs-api/tests/unit/domains/test_contribution_service.py new file mode 100644 index 000000000..02451f503 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_contribution_service.py @@ -0,0 +1,1060 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import polars as pl +import pytest +from beanie import PydanticObjectId +from pymatgen.core import Element +from pymongo.errors import BulkWriteError + +from mpcontribs_api.authz import ADMIN_GROUP, User +from mpcontribs_api.config import MongoSettings +from mpcontribs_api.domains.attachments.models import Attachment, AttachmentIn +from mpcontribs_api.domains.contributions.models import Contribution, ContributionIn +from mpcontribs_api.domains.contributions.service import ContributionService +from mpcontribs_api.domains.structures.models import ( + Lattice, + Site, + SiteProperties, + Species, + Structure, + StructureIn, +) +from mpcontribs_api.domains.tables.models import Attributes, Labels, Table, TableIn +from mpcontribs_api.exceptions import ConflictError, ValidationError + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _oid() -> PydanticObjectId: + return PydanticObjectId() + + +def _attachment_in(**overrides) -> AttachmentIn: + defaults = { + "_id": _oid(), + "name": "data.gz", + "md5": "a" * 32, + "mime": "application/gzip", + "content": 0, + } + defaults.update(overrides) + return AttachmentIn(**defaults) + + +def _table_in(**overrides) -> TableIn: + defaults = { + "_id": _oid(), + "name": "test-table", + "md5": "b" * 32, + "attrs": Attributes(title="T", labels=Labels(index="x", value="y", variable="z")), + "total_data_rows": 1, + "data": pl.DataFrame({"col": [1.0]}), + } + defaults.update(overrides) + return TableIn(**defaults) + + +def _structure_in(**overrides) -> StructureIn: + defaults = { + "_id": _oid(), + "name": "test-struct", + "md5": "c" * 32, + "lattice": Lattice( + matrix=pl.DataFrame([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), + pbc=[True, True, True], + a=1.0, b=1.0, c=1.0, + alpha=90.0, beta=90.0, gamma=90.0, + volume=1.0, + ), + "sites": [ + Site( + species=[Species(element=Element("Fe"), occu=1)], + abc=[0.0, 0.0, 0.0], + properties=SiteProperties(magmom=0.0), + label="Fe", + xyz=[0.0, 0.0, 0.0], + ) + ], + "charge": None, + "cif": "", + } + defaults.update(overrides) + return StructureIn(**defaults) + + +def _contrib_in(project="proj", identifier="mp-1", formula="Fe2O3", **kwargs) -> ContributionIn: + return ContributionIn( + _id=_oid(), + project=project, + identifier=identifier, + formula=formula, + data={}, + **kwargs, + ) + + +def _make_mongo_settings( + *, + max_components_per_contribution: int = 500, + max_concurrent_transactions: int = 8, + component_insert_chunk_size: int = 100, +) -> MongoSettings: + return MongoSettings.model_validate({ + "uri": "mongodb://test", + "db_name": "test", + "max_pool_size": 100, + "max_components_per_contribution": max_components_per_contribution, + "max_concurrent_transactions": max_concurrent_transactions, + "component_insert_chunk_size": component_insert_chunk_size, + }) + + +def _make_fake_client() -> tuple[AsyncMock, MagicMock]: + """Return a fake AsyncMongoClient whose start_session() yields a session that drives + with_transaction(callback) by simply awaiting callback(session). + + Returns: + (client, session): the session is exposed so tests can assert on it. + """ + session = MagicMock(name="session") + + async def _with_transaction(callback): + return await callback(session) + + session.with_transaction = AsyncMock(side_effect=_with_transaction) + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=None) + + client = MagicMock(name="client") + client.start_session = MagicMock(return_value=session) + return client, session + + +def _admin_user() -> User: + """An admin user — bypasses project write authorization, so tests not exercising authz + keep their previous behavior.""" + return User(username="admin", groups=frozenset({ADMIN_GROUP})) + + +def _make_service( + contributions=None, + structures=None, + tables=None, + attachments=None, + client=None, + settings: MongoSettings | None = None, + write_slots: asyncio.Semaphore | None = None, + user: User | None = None, + projects=None, + unique_identifiers: bool = True, +) -> tuple[ContributionService, AsyncMock, AsyncMock, AsyncMock, AsyncMock, MagicMock]: + contrib_repo = contributions or AsyncMock() + struct_repo = structures or AsyncMock() + table_repo = tables or AsyncMock() + attach_repo = attachments or AsyncMock() + # Default version resolution: every referenced project reports the ``unique_identifiers`` flag + # and no contribution version exists yet, so the common path resolves version == 1 with no + # conflict. Tests exercising versioning pass their own ``projects`` mock or override + # ``contrib_repo.max_versions``. + projects_repo = projects or AsyncMock() + if projects is None: + projects_repo.unique_identifiers_by_id.side_effect = lambda ids: {pid: unique_identifiers for pid in ids} + contrib_repo.max_versions.return_value = {} + if client is None: + client, _ = _make_fake_client() + svc = ContributionService( + client=client, + user=user or _admin_user(), + projects=projects_repo, + contributions=contrib_repo, + structures=struct_repo, + tables=table_repo, + attachments=attach_repo, + settings=settings or _make_mongo_settings(), + ) + return svc, contrib_repo, struct_repo, table_repo, attach_repo, client + + +def _fake_structure() -> Structure: + s = MagicMock(spec=Structure) + s.id = _oid() + return s + + +def _fake_table() -> Table: + t = MagicMock(spec=Table) + t.id = _oid() + return t + + +def _fake_attachment() -> Attachment: + a = MagicMock(spec=Attachment) + a.id = _oid() + return a + + +# --------------------------------------------------------------------------- +# insert_contributions — pre-checks (cheap, no DB) +# --------------------------------------------------------------------------- + + +class TestInsertContributionsPreChecks: + async def test_empty_batch_returns_empty_summary_no_db(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() + + summary = await svc.insert_contributions([]) + + assert summary.total == 0 + assert summary.succeeded == [] + assert summary.failed == [] + contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_contribution.assert_not_called() + client.start_session.assert_not_called() + + async def test_duplicate_project_identifier_unique_project_conflicts_later_item(self): + """On a unique-identifier project, a repeated (project, identifier) in one batch no longer + fails the whole request (the old _reject_duplicate_keys behavior). The first occurrence is + inserted at version 1; later duplicates are per-item conflict failures.""" + svc, contrib_repo, _, _, _, client = _make_service() + contrib_repo.insert_many_contributions.return_value = None + contribs = [ + _contrib_in(project="p", identifier="dup"), + _contrib_in(project="p", identifier="dup"), + ] + summary = await svc.insert_contributions(contribs) + + assert summary.total == 2 + assert len(summary.succeeded) == 1 + assert summary.succeeded[0].version == 1 + assert [f.index for f in summary.failed] == [1] + assert summary.failed[0].error_code == "conflict" + # Index 0 still reached Mongo (one doc inserted) + assert len(contrib_repo.insert_many_contributions.call_args[0][0]) == 1 + + async def test_oversize_contribution_goes_to_failures_without_db(self): + settings = _make_mongo_settings(max_components_per_contribution=1) + svc, contrib_repo, struct_repo, _, _, client = _make_service(settings=settings) + contrib_repo.insert_many_contributions.return_value = None + + good = _contrib_in(identifier="ok") + oversize = _contrib_in(identifier="big", structures=[_structure_in(), _structure_in()]) + + summary = await svc.insert_contributions([good, oversize]) + + assert summary.total == 2 + assert len(summary.failed) == 1 + assert summary.failed[0].index == 1 + assert summary.failed[0].error_code == "validation_error" + # Oversize never reached the component repo + struct_repo.insert_components.assert_not_called() + # And the in-pool contribution did go through the no-component fast path + contrib_repo.insert_many_contributions.assert_called_once() + + +# --------------------------------------------------------------------------- +# insert_contributions — no-component fast path +# --------------------------------------------------------------------------- + + +class TestInsertContributionsNoComponentPath: + async def test_all_no_components_uses_single_insert_many(self): + svc, contrib_repo, _, _, _, client = _make_service() + contrib_repo.insert_many_contributions.return_value = None + + contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] + summary = await svc.insert_contributions(contribs) + + contrib_repo.insert_many_contributions.assert_called_once() + # Zero transactions opened + client.start_session.assert_not_called() + contrib_repo.insert_contribution.assert_not_called() + assert summary.total == 3 + assert len(summary.succeeded) == 3 + assert summary.failed == [] + + async def test_is_public_forced_false_on_inserted_docs(self): + svc, contrib_repo, _, _, _, _ = _make_service() + contrib_repo.insert_many_contributions.return_value = None + + await svc.insert_contributions([_contrib_in()]) + + docs = contrib_repo.insert_many_contributions.call_args[0][0] + assert all(d.is_public is False for d in docs) + + async def test_bulk_write_error_partitions_succeeded_and_failed(self): + svc, contrib_repo, _, _, _, _ = _make_service() + # writeErrors index refers to position in the docs list (post-partition); both 2 and 5 + # exercise the mapping back to original input indices. + bulk_err = BulkWriteError({ + "writeErrors": [ + {"index": 2, "code": 11000, "errmsg": "duplicate key"}, + {"index": 5, "code": 11000, "errmsg": "duplicate key"}, + ] + }) + contrib_repo.insert_many_contributions.side_effect = bulk_err + + contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(6)] + summary = await svc.insert_contributions(contribs) + + assert summary.total == 6 + assert sorted(f.index for f in summary.failed) == [2, 5] + assert all(f.error_code == "conflict" for f in summary.failed) + # The 4 unaffected contributions succeed + assert len(summary.succeeded) == 4 + + +# --------------------------------------------------------------------------- +# insert_contributions — per-contribution transaction path +# --------------------------------------------------------------------------- + + +class TestInsertContributionsTransactionPath: + async def test_with_components_opens_session_per_contribution(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() + + struct_repo.insert_components.return_value = [_fake_structure()] + table_repo.insert_components.return_value = [] + attach_repo.insert_components.return_value = [] + + async def _insert(doc, session=None): + return doc + + contrib_repo.insert_contribution.side_effect = _insert + + contribs = [_contrib_in(identifier=f"c{i}", structures=[_structure_in()]) for i in range(3)] + summary = await svc.insert_contributions(contribs) + + assert client.start_session.call_count == 3 + assert summary.total == 3 + assert len(summary.succeeded) == 3 + assert summary.failed == [] + + async def test_session_threaded_to_all_repo_calls(self): + client, session = _make_fake_client() + svc, contrib_repo, struct_repo, table_repo, _, _ = _make_service(client=client) + + struct_repo.insert_components.return_value = [_fake_structure()] + table_repo.insert_components.return_value = [_fake_table()] + + async def _insert(doc, session=None): + return doc + + contrib_repo.insert_contribution.side_effect = _insert + + contrib = _contrib_in( + structures=[_structure_in()], + tables=[_table_in()], + attachments=[_attachment_in()], + ) + await svc.insert_contributions([contrib]) + assert struct_repo.insert_components.call_args.kwargs["session"] is session + assert table_repo.insert_components.call_args.kwargs["session"] is session + assert contrib_repo.insert_contribution.call_args.kwargs["session"] is session + + async def test_failure_on_second_of_three_yields_summary(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + + struct_repo.insert_components.return_value = [_fake_structure()] + table_repo.insert_components.return_value = [] + attach_repo.insert_components.return_value = [] + + async def _insert(doc, session=None): + # Fail the second contribution by inspecting the doc identifier + if doc.identifier == "fail": + raise ConflictError("conflict on insert") + return doc + + contrib_repo.insert_contribution.side_effect = _insert + + contribs = [ + _contrib_in(identifier="ok-1", structures=[_structure_in()]), + _contrib_in(identifier="fail", structures=[_structure_in()]), + _contrib_in(identifier="ok-2", structures=[_structure_in()]), + ] + summary = await svc.insert_contributions(contribs) + + assert summary.total == 3 + assert len(summary.succeeded) == 2 + assert len(summary.failed) == 1 + assert summary.failed[0].index == 1 + assert summary.failed[0].error_code == "conflict" + + async def test_component_links_wired_per_contribution(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + + struct_a, struct_b = _fake_structure(), _fake_structure() + struct_calls = iter([[struct_a], [struct_b]]) + struct_repo.insert_components.side_effect = lambda *_args, **_kwargs: next(struct_calls) + table_repo.insert_components.return_value = [] + attach_repo.insert_components.return_value = [] + + captured: list[Contribution] = [] + + async def _insert(doc, session=None): + captured.append(doc) + return doc + + contrib_repo.insert_contribution.side_effect = _insert + + contribs = [ + _contrib_in(identifier="a", structures=[_structure_in()]), + _contrib_in(identifier="b", structures=[_structure_in()]), + ] + await svc.insert_contributions(contribs) + + captured_by_id = {c.identifier: c for c in captured} + assert captured_by_id["a"].structures == [struct_a] + assert captured_by_id["b"].structures == [struct_b] + + +# --------------------------------------------------------------------------- +# insert_contributions — mixed batch (partitioned across paths) +# --------------------------------------------------------------------------- + + +class TestInsertContributionsMixedBatch: + async def test_mixed_batch_routes_correctly(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() + + struct_repo.insert_components.return_value = [_fake_structure()] + table_repo.insert_components.return_value = [] + attach_repo.insert_components.return_value = [] + contrib_repo.insert_many_contributions.return_value = None + + async def _insert(doc, session=None): + return doc + + contrib_repo.insert_contribution.side_effect = _insert + + contribs = [ + _contrib_in(identifier="bare-1"), + _contrib_in(identifier="with-1", structures=[_structure_in()]), + _contrib_in(identifier="bare-2"), + _contrib_in(identifier="with-2", structures=[_structure_in()]), + ] + summary = await svc.insert_contributions(contribs) + + # No-component path: single batched call + contrib_repo.insert_many_contributions.assert_called_once() + assert len(contrib_repo.insert_many_contributions.call_args[0][0]) == 2 + # With-component path: one session per item + assert client.start_session.call_count == 2 + assert contrib_repo.insert_contribution.call_count == 2 + assert summary.total == 4 + assert len(summary.succeeded) == 4 + assert summary.failed == [] + + +# --------------------------------------------------------------------------- +# Versioning — uniqueness rules + version assignment (insert & upsert) +# --------------------------------------------------------------------------- + + +def _projects_mock(unique_map: dict[str, bool]) -> AsyncMock: + """A projects repo whose unique_identifiers_by_id returns only the known projects.""" + repo = AsyncMock() + repo.unique_identifiers_by_id.side_effect = lambda ids: {pid: unique_map[pid] for pid in ids if pid in unique_map} + return repo + + +class TestContributionVersioning: + async def test_insert_unique_existing_key_conflicts(self): + svc, contrib_repo, *_ = _make_service() # unique_identifiers=True by default + contrib_repo.insert_many_contributions.return_value = None + contrib_repo.max_versions.return_value = {("proj", "mp-1"): 1} + + summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + + assert summary.total == 1 + assert summary.succeeded == [] + assert [f.error_code for f in summary.failed] == ["conflict"] + contrib_repo.insert_many_contributions.assert_not_called() + + async def test_insert_non_unique_assigns_max_plus_one(self): + svc, contrib_repo, *_ = _make_service(unique_identifiers=False) + contrib_repo.insert_many_contributions.return_value = None + contrib_repo.max_versions.return_value = {("proj", "mp-1"): 3} + + summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + + assert len(summary.succeeded) == 1 + assert summary.succeeded[0].version == 4 + docs = contrib_repo.insert_many_contributions.call_args[0][0] + assert docs[0].version == 4 + + async def test_insert_non_unique_no_existing_starts_at_one(self): + svc, contrib_repo, *_ = _make_service(unique_identifiers=False) + contrib_repo.insert_many_contributions.return_value = None + # default max_versions -> {} (no existing contributions) + + summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + + assert summary.succeeded[0].version == 1 + + async def test_insert_non_unique_same_key_twice_sequences_versions(self): + svc, contrib_repo, *_ = _make_service(unique_identifiers=False) + contrib_repo.insert_many_contributions.return_value = None + contrib_repo.max_versions.return_value = {("proj", "dup"): 5} + + contribs = [_contrib_in(identifier="dup"), _contrib_in(identifier="dup")] + summary = await svc.insert_contributions(contribs) + + assert len(summary.succeeded) == 2 + assert sorted(d.version for d in summary.succeeded) == [6, 7] + + async def test_insert_project_not_found_is_validation_failure(self): + svc, contrib_repo, *_ = _make_service(projects=_projects_mock({})) # no projects known + + summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + + assert summary.succeeded == [] + assert [f.error_code for f in summary.failed] == ["validation_error"] + contrib_repo.insert_many_contributions.assert_not_called() + + async def test_upsert_unique_forces_version_one(self): + svc, contrib_repo, *_ = _make_service() # unique by default + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + # A supplied version is ignored for unique-identifier projects (inferred as 1). + await svc.upsert_contributions([_contrib_in(identifier="mp-1", version=9)]) + + assert contrib_repo.upsert_contribution_by_identifiers.call_args.args[2] == 1 + contrib_repo.max_versions.assert_not_called() + + async def test_upsert_non_unique_requires_version(self): + svc, contrib_repo, *_ = _make_service(unique_identifiers=False) + + summary = await svc.upsert_contributions([_contrib_in(identifier="mp-1")]) # no version + + assert summary.succeeded == [] + assert [f.error_code for f in summary.failed] == ["validation_error"] + contrib_repo.upsert_contribution_by_identifiers.assert_not_called() + + async def test_upsert_non_unique_passes_supplied_version(self): + svc, contrib_repo, *_ = _make_service(unique_identifiers=False) + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + await svc.upsert_contributions([_contrib_in(identifier="mp-1", version=7)]) + + assert contrib_repo.upsert_contribution_by_identifiers.call_args.args[2] == 7 + + +# --------------------------------------------------------------------------- +# upsert_contributions — guard clause +# --------------------------------------------------------------------------- + + +class TestUpsertContributionsGuard: + async def test_raises_validation_error_when_any_contrib_has_structures(self): + svc, *_ = _make_service() + contrib = _contrib_in(structures=[_structure_in()]) + with pytest.raises(ValidationError): + await svc.upsert_contributions([contrib]) + + async def test_raises_validation_error_when_any_contrib_has_tables(self): + svc, *_ = _make_service() + contrib = _contrib_in(tables=[_table_in()]) + with pytest.raises(ValidationError): + await svc.upsert_contributions([contrib]) + + async def test_raises_validation_error_when_any_contrib_has_attachments(self): + svc, *_ = _make_service() + contrib = _contrib_in(attachments=[_attachment_in()]) + with pytest.raises(ValidationError): + await svc.upsert_contributions([contrib]) + + async def test_error_reports_indices_of_offending_contribs(self): + svc, *_ = _make_service() + clean = _contrib_in(identifier="clean") + dirty = _contrib_in(identifier="dirty", structures=[_structure_in()]) + with pytest.raises(ValidationError) as exc_info: + await svc.upsert_contributions([clean, dirty]) + assert exc_info.value.context.get("contribution_indices") == [1] + + async def test_multiple_offenders_all_indices_reported(self): + svc, *_ = _make_service() + contribs = [ + _contrib_in(identifier="c0", structures=[_structure_in()]), + _contrib_in(identifier="c1"), + _contrib_in(identifier="c2", tables=[_table_in()]), + ] + with pytest.raises(ValidationError) as exc_info: + await svc.upsert_contributions(contribs) + assert exc_info.value.context.get("contribution_indices") == [0, 2] + + async def test_raises_before_any_db_write(self): + svc, contrib_repo, *_ = _make_service() + dirty = _contrib_in(structures=[_structure_in()]) + with pytest.raises(ValidationError): + await svc.upsert_contributions([dirty]) + contrib_repo.upsert_contribution_by_identifiers.assert_not_called() + contrib_repo.find_one_contribution.assert_not_called() + contrib_repo.insert_contribution.assert_not_called() + contrib_repo.update_contribution.assert_not_called() + + +# --------------------------------------------------------------------------- +# upsert_contributions — atomic dispatch +# --------------------------------------------------------------------------- + + +class TestUpsertContributionsAtomic: + async def test_calls_atomic_repo_method_once_per_item(self): + svc, contrib_repo, *_ = _make_service() + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] + summary = await svc.upsert_contributions(contribs) + + assert summary.total == 3 + assert len(summary.succeeded) == 3 + assert summary.failed == [] + assert contrib_repo.upsert_contribution_by_identifiers.call_count == 3 + # The legacy read-then-write path must not be used + contrib_repo.find_one_contribution.assert_not_called() + contrib_repo.update_contribution.assert_not_called() + contrib_repo.insert_contribution.assert_not_called() + + async def test_passes_identifiers_dict_and_input_to_repo(self): + svc, contrib_repo, *_ = _make_service() + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + contrib = _contrib_in(project="my-proj", identifier="mp-99") + await svc.upsert_contributions([contrib]) + + call = contrib_repo.upsert_contribution_by_identifiers.call_args + assert call.args[0] == {"project": "my-proj", "identifier": "mp-99"} + assert call.args[1] is contrib + + async def test_returns_repo_results_in_input_order(self): + svc, contrib_repo, *_ = _make_service() + docs = [MagicMock(spec=Contribution, name=f"doc-{i}") for i in range(3)] + returned = {} + + async def _upsert(identifiers, contrib, version): + doc = docs[int(contrib.identifier.split("-")[1])] + returned[contrib.identifier] = doc + return doc + + contrib_repo.upsert_contribution_by_identifiers.side_effect = _upsert + + contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] + summary = await svc.upsert_contributions(contribs) + + assert summary.succeeded == [returned["mp-0"], returned["mp-1"], returned["mp-2"]] + + async def test_empty_batch_returns_empty_summary(self): + svc, contrib_repo, *_ = _make_service() + summary = await svc.upsert_contributions([]) + assert summary.total == 0 + assert summary.succeeded == [] + assert summary.failed == [] + contrib_repo.upsert_contribution_by_identifiers.assert_not_called() + + async def test_same_key_concurrent_upserts_both_go_through_atomic_call(self): + """Race-safety regression: two items with the same (project, identifier) in one batch + must both reach the atomic repo method. The repo (via the unique index) is the + tiebreaker — the service must not pre-deduplicate or otherwise swallow one. + """ + svc, contrib_repo, *_ = _make_service() + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + contribs = [ + _contrib_in(project="p", identifier="same"), + _contrib_in(project="p", identifier="same"), + ] + summary = await svc.upsert_contributions(contribs) + + assert len(summary.succeeded) == 2 + assert contrib_repo.upsert_contribution_by_identifiers.call_count == 2 + + async def test_one_failure_is_reported_not_raised(self): + svc, contrib_repo, *_ = _make_service() + + async def _upsert(identifiers, contrib, version): + if contrib.identifier == "mp-1": + raise ConflictError("boom") + return MagicMock(spec=Contribution) + + contrib_repo.upsert_contribution_by_identifiers.side_effect = _upsert + + contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] + summary = await svc.upsert_contributions(contribs) + + assert summary.total == 3 + assert len(summary.succeeded) == 2 + assert [f.index for f in summary.failed] == [1] + assert summary.failed[0].error_code == "conflict" + + +# --------------------------------------------------------------------------- +# Project-scoped write authorization (insert + upsert) +# --------------------------------------------------------------------------- + + +def _member_user(*projects: str) -> User: + """Non-admin user whose writable projects are exactly ``projects``.""" + return User(username="alice", groups=frozenset(projects)) + + +class TestWriteAuthorization: + async def test_insert_rejects_unauthorized_project_per_item(self): + svc, contrib_repo, struct_repo, _, _, client = _make_service(user=_member_user("allowed")) + contrib_repo.insert_many_contributions.return_value = None + + contribs = [ + _contrib_in(project="allowed", identifier="ok"), + _contrib_in(project="forbidden", identifier="nope"), + ] + summary = await svc.insert_contributions(contribs) + + assert summary.total == 2 + assert len(summary.succeeded) == 1 + assert [f.index for f in summary.failed] == [1] + assert summary.failed[0].error_code == "permission_denied" + assert "forbidden" in summary.failed[0].message + # Only the authorized item reached Mongo + contrib_repo.insert_many_contributions.assert_called_once() + + async def test_insert_admin_bypasses_authorization(self): + svc, contrib_repo, *_ = _make_service() # default user is admin + contrib_repo.insert_many_contributions.return_value = None + + contribs = [_contrib_in(project="anything", identifier=f"mp-{i}") for i in range(2)] + summary = await svc.insert_contributions(contribs) + + assert summary.total == 2 + assert len(summary.succeeded) == 2 + assert summary.failed == [] + + async def test_insert_unauthorized_and_oversize_yield_single_failure(self): + """An item that is both unauthorized and oversize must produce exactly one BulkFailure + (authorization runs first), preserving total == len(contributions).""" + settings = _make_mongo_settings(max_components_per_contribution=1) + svc, contrib_repo, struct_repo, _, _, _ = _make_service(user=_member_user("allowed"), settings=settings) + + bad = _contrib_in(project="forbidden", identifier="big", structures=[_structure_in(), _structure_in()]) + summary = await svc.insert_contributions([bad]) + + assert summary.total == 1 + assert len(summary.failed) == 1 + assert summary.failed[0].index == 0 + assert summary.failed[0].error_code == "permission_denied" + struct_repo.insert_components.assert_not_called() + + async def test_upsert_rejects_unauthorized_project_per_item(self): + svc, contrib_repo, *_ = _make_service(user=_member_user("allowed")) + contrib_repo.upsert_contribution_by_identifiers.return_value = MagicMock(spec=Contribution) + + contribs = [ + _contrib_in(project="allowed", identifier="ok"), + _contrib_in(project="forbidden", identifier="nope"), + ] + summary = await svc.upsert_contributions(contribs) + + assert summary.total == 2 + assert len(summary.succeeded) == 1 + assert [f.index for f in summary.failed] == [1] + assert summary.failed[0].error_code == "permission_denied" + assert "forbidden" in summary.failed[0].message + # Only the authorized item reached the atomic repo method + contrib_repo.upsert_contribution_by_identifiers.assert_called_once() + + async def test_upsert_anonymous_authorized_for_nothing(self): + svc, contrib_repo, *_ = _make_service(user=User()) # anonymous: no username, no groups + + summary = await svc.upsert_contributions([_contrib_in(project="any", identifier="x")]) + + assert summary.total == 1 + assert summary.succeeded == [] + assert [f.error_code for f in summary.failed] == ["permission_denied"] + contrib_repo.upsert_contribution_by_identifiers.assert_not_called() + + +# --------------------------------------------------------------------------- +# Process-wide write_slots semaphore is honored +# --------------------------------------------------------------------------- + + +# class TestProcessWideWriteSlots: +# async def test_upsert_acquires_global_write_slot(self): +# write_slots = asyncio.Semaphore(1) +# svc, contrib_repo, *_ = _make_service(write_slots=write_slots) + +# in_flight = 0 +# peak = 0 + +# async def _upsert(identifiers, contrib): +# nonlocal in_flight, peak +# in_flight += 1 +# peak = max(peak, in_flight) +# await asyncio.sleep(0) # let other coroutines try to enter +# in_flight -= 1 +# return MagicMock(spec=Contribution) + +# contrib_repo.upsert_contribution_by_identifiers.side_effect = _upsert + +# contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(5)] +# await svc.upsert_contributions(contribs) + +# assert peak == 1 # global semaphore of 1 must serialize all 5 + +# async def test_insert_with_components_acquires_global_write_slot(self): +# write_slots = asyncio.Semaphore(1) +# svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service(write_slots=write_slots) + +# struct_repo.insert_components.return_value = [_fake_structure()] +# table_repo.insert_components.return_value = [] +# attach_repo.insert_components.return_value = [] + +# in_flight = 0 +# peak = 0 + +# async def _insert(doc, session=None): +# nonlocal in_flight, peak +# in_flight += 1 +# peak = max(peak, in_flight) +# await asyncio.sleep(0) +# in_flight -= 1 +# return doc + +# contrib_repo.insert_contribution.side_effect = _insert + +# contribs = [_contrib_in(identifier=f"c{i}", structures=[_structure_in()]) for i in range(4)] +# await svc.insert_contributions(contribs) + +# assert peak == 1 + + +# --------------------------------------------------------------------------- +# delete_contributions — cascade delete (components-first), cursor loop +# --------------------------------------------------------------------------- + +from types import SimpleNamespace # noqa: E402 + +from mpcontribs_api.domains._shared.models import DeleteResponse # noqa: E402 +from mpcontribs_api.domains.contributions.models import ContributionFilter # noqa: E402 +from mpcontribs_api.pagination import Page # noqa: E402 + + +def _link(ref_id: PydanticObjectId) -> SimpleNamespace: + """Minimal stand-in for a Beanie Link: only ``.ref.id`` is read by the service.""" + return SimpleNamespace(ref=SimpleNamespace(id=ref_id)) + + +def _contrib_doc(structures=None, attachments=None, tables=None, id_=None) -> SimpleNamespace: + """A contribution page item exposing the attributes delete_contributions reads.""" + return SimpleNamespace( + id=id_ or _oid(), + structures=[_link(s) for s in (structures or [])], + attachments=[_link(a) for a in (attachments or [])], + tables=[_link(t) for t in (tables or [])], + ) + + +def _page(items) -> Page: + return Page(items=items, next_cursor=None) + + +def _delete_result(n: int) -> SimpleNamespace: + """Stand-in for pymongo DeleteResult (only ``.deleted_count`` is read).""" + return SimpleNamespace(deleted_count=n) + + +def _noop_filter() -> ContributionFilter: + return ContributionFilter() + + +class TestDeleteContributionsEmpty: + async def test_empty_match_returns_zero_summary(self): + svc, contrib_repo, *_ = _make_service() + contrib_repo.get_contributions.return_value = _page([]) + contrib_repo.delete_contributions.return_value = _delete_result(0) + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_deleted == 0 + assert summary.num_children_deleted == 0 + + async def test_empty_match_does_not_call_child_repos(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + contrib_repo.get_contributions.return_value = _page([]) + contrib_repo.delete_contributions.return_value = _delete_result(0) + + await svc.delete_contributions(_noop_filter()) + + struct_repo.delete_by_ids.assert_not_called() + table_repo.delete_by_ids.assert_not_called() + attach_repo.delete_by_ids.assert_not_called() + + async def test_empty_match_terminates_after_one_page(self): + svc, contrib_repo, *_ = _make_service() + contrib_repo.get_contributions.return_value = _page([]) + contrib_repo.delete_contributions.return_value = _delete_result(0) + + await svc.delete_contributions(_noop_filter()) + + assert contrib_repo.get_contributions.await_count == 1 + + +class TestDeleteContributionsSinglePage: + async def test_deletes_contributions_then_terminates(self): + svc, contrib_repo, *_ = _make_service() + docs = [_contrib_doc() for _ in range(3)] + # First call returns the page; second returns empty so the loop ends. + contrib_repo.get_contributions.side_effect = [_page(docs), _page([])] + contrib_repo.delete_contributions.side_effect = [_delete_result(3), _delete_result(0)] + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_deleted == 3 + + async def test_no_components_means_no_child_deletes(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + contrib_repo.get_contributions.side_effect = [_page([_contrib_doc()]), _page([])] + contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + + summary = await svc.delete_contributions(_noop_filter()) + + struct_repo.delete_by_ids.assert_not_called() + table_repo.delete_by_ids.assert_not_called() + attach_repo.delete_by_ids.assert_not_called() + assert summary.num_children_deleted == 0 + + async def test_components_deleted_before_contributions(self): + # Records call order across repos to assert children go first. + order: list[str] = [] + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + + doc = _contrib_doc(structures=[_oid()], tables=[_oid()], attachments=[_oid()]) + contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] + + def _make_child_recorder(name): + async def _record(ids, *a, **k): + order.append(name) + return DeleteResponse(num_deleted=1) + + return _record + + struct_repo.delete_by_ids.side_effect = _make_child_recorder("structures") + table_repo.delete_by_ids.side_effect = _make_child_recorder("tables") + attach_repo.delete_by_ids.side_effect = _make_child_recorder("attachments") + + async def _record_contrib(_filter, *a, **k): + order.append("contributions") + return _delete_result(1) + + contrib_repo.delete_contributions.side_effect = _record_contrib + + await svc.delete_contributions(_noop_filter()) + + # The loop makes a final pass on the empty page that still issues one + # (no-op) contribution delete before breaking, so there are two + # "contributions" entries. The invariant under test: all three child + # deletes happen before the first contribution delete. + first_contrib = order.index("contributions") + assert set(order[:first_contrib]) == {"structures", "tables", "attachments"} + + async def test_child_ids_collected_from_links(self): + svc, contrib_repo, struct_repo, *_ = _make_service() + s1, s2 = _oid(), _oid() + doc = _contrib_doc(structures=[s1, s2]) + contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] + struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=2) + contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + + await svc.delete_contributions(_noop_filter()) + + called_ids = struct_repo.delete_by_ids.await_args.args[0] + assert set(called_ids) == {s1, s2} + + async def test_child_counts_accumulated_across_types(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + doc = _contrib_doc(structures=[_oid()], tables=[_oid(), _oid()], attachments=[_oid()]) + contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] + struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) + table_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=2) + attach_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) + contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_children_deleted == 4 + + async def test_contributions_deleted_by_id_in_of_page(self): + svc, contrib_repo, *_ = _make_service() + ids = [_oid(), _oid()] + docs = [_contrib_doc(id_=i) for i in ids] + contrib_repo.get_contributions.side_effect = [_page(docs), _page([])] + contrib_repo.delete_contributions.side_effect = [_delete_result(2), _delete_result(0)] + + await svc.delete_contributions(_noop_filter()) + + first_call_filter = contrib_repo.delete_contributions.await_args_list[0].args[0] + assert set(first_call_filter.id__in) == set(ids) + + +class TestDeleteContributionsMultiPage: + async def test_loops_until_page_empty(self): + svc, contrib_repo, *_ = _make_service() + contrib_repo.get_contributions.side_effect = [ + _page([_contrib_doc() for _ in range(2)]), + _page([_contrib_doc()]), + _page([]), + ] + contrib_repo.delete_contributions.side_effect = [ + _delete_result(2), + _delete_result(1), + _delete_result(0), + ] + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_deleted == 3 + assert contrib_repo.get_contributions.await_count == 3 + + async def test_children_accumulate_across_pages(self): + svc, contrib_repo, struct_repo, *_ = _make_service() + contrib_repo.get_contributions.side_effect = [ + _page([_contrib_doc(structures=[_oid()])]), + _page([_contrib_doc(structures=[_oid()])]), + _page([]), + ] + struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) + contrib_repo.delete_contributions.side_effect = [ + _delete_result(1), + _delete_result(1), + _delete_result(0), + ] + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_children_deleted == 2 + assert struct_repo.delete_by_ids.await_count == 2 + + +class TestDeleteContributionsNoneComponents: + """ContributionOut leaves unset component fields as None (not []). + + The cascade loop must tolerate None rather than raising TypeError on iteration. + """ + + async def test_none_component_fields_do_not_raise(self): + svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() + doc = SimpleNamespace(id=_oid(), structures=None, tables=None, attachments=None) + contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] + contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + + summary = await svc.delete_contributions(_noop_filter()) + + assert summary.num_deleted == 1 + assert summary.num_children_deleted == 0 + struct_repo.delete_by_ids.assert_not_called() + table_repo.delete_by_ids.assert_not_called() + attach_repo.delete_by_ids.assert_not_called() diff --git a/mpcontribs-api/tests/unit/domains/test_contributions_models.py b/mpcontribs-api/tests/unit/domains/test_contributions_models.py new file mode 100644 index 000000000..acabd1825 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_contributions_models.py @@ -0,0 +1,287 @@ +from datetime import UTC, datetime + +import pytest +from beanie import PydanticObjectId +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.authz import User +from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository + +from mpcontribs_api.domains.contributions.models import ( + Contribution, + ContributionFilter, + ContributionIn, + ContributionOut, + ContributionPatch, +) +from mpcontribs_api.exceptions import ValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_contribution_in(**overrides) -> ContributionIn: + """Build a minimal valid ContributionIn for testing.""" + defaults: dict = { + "_id": PydanticObjectId(), + "project": "test-project", + "identifier": "mp-1234", + "formula": "Fe2O3", + "data": {"band_gap": {"value": 2.1, "unit": "eV"}}, + } + defaults.update(overrides) + return ContributionIn(**defaults) + + +# --------------------------------------------------------------------------- +# ContributionBase field validation +# --------------------------------------------------------------------------- + + +class TestContributionBase: + def test_required_fields_set_correctly(self): + contrib = ContributionIn( + **{ + "_id": PydanticObjectId(), + "project": "mp-project", + "identifier": "mp-001", + "formula": "Fe2O3", + "data": {}, + } + ) + assert contrib.project == "mp-project" + assert contrib.identifier == "mp-001" + assert contrib.formula == "Fe2O3" + assert contrib.data == {} + + def test_defaults(self): + contrib = _make_contribution_in() + assert contrib.needs_build is True + assert contrib.structures is None + assert contrib.tables is None + assert contrib.attachments is None + + def test_last_modified_defaults_to_now(self): + before = datetime.now(UTC) + contrib = _make_contribution_in() + after = datetime.now(UTC) + assert before <= contrib.last_modified <= after + + def test_missing_project_raises(self): + with pytest.raises(PydanticValidationError): + ContributionIn( + **{ + "_id": PydanticObjectId(), + "identifier": "mp-001", + "formula": "Fe", + "data": {}, + } + ) + + def test_missing_formula_raises(self): + with pytest.raises(PydanticValidationError): + ContributionIn( + **{ + "_id": PydanticObjectId(), + "project": "proj", + "identifier": "mp-001", + "data": {}, + } + ) + + def test_data_can_be_empty_dict(self): + contrib = _make_contribution_in(data={}) + assert contrib.data == {} + + def test_data_accepts_nested_structure(self): + nested = {"band_gap": {"value": 1.5, "unit": "eV"}, "volume": 42.3} + contrib = _make_contribution_in(data=nested) + assert contrib.data["band_gap"]["value"] == 1.5 + + def test_data_depth_validation(self): + max_nesting = {"lvl_1": {"lvl_2": {"lvl_3": {"lvl_4": {"lvl_5": {"lvl_6": {"lvl_7": "pass"}}}}}}} + invalid_nesting = {"lvl_1": {"lvl_2": {"lvl_3": {"lvl_4": {"lvl_5": {"lvl_6": {"lvl_7": {"lvl_8": "fail"}}}}}}}} + _make_contribution_in(data=max_nesting) + assert True + with pytest.raises(ValidationError, match="Depth of Contribution.data"): + _make_contribution_in(data=invalid_nesting) + + def test_data_key_validation(self): + valid_punctuation = {"test*/|": "pass"} + invalid_punctuation = {"test.": "fail"} + too_many_pipes = {"test||": "fail"} + non_ascii = {"ΔE": "fail"} + _make_contribution_in(data=valid_punctuation) + assert True + with pytest.raises(ValidationError, match="Punctuation found in Contribution.data keys"): + _make_contribution_in(data=invalid_punctuation) + with pytest.raises(ValidationError, match="Punctuation found in Contribution.data keys"): + _make_contribution_in(data=too_many_pipes) + with pytest.raises(ValidationError, match="Non-ASCII key found in Contribution.data"): + _make_contribution_in(data=non_ascii) + + # There isn't currently value validation. This is to check that that is true + def test_data_value_validation(self): + pipes_in_values = {"test": "pass||"} + punctuation_in_values = {"test": "pass."} + ascii_in_values = {"test": "Δ"} + _make_contribution_in(data=pipes_in_values) + _make_contribution_in(data=punctuation_in_values) + _make_contribution_in(data=ascii_in_values) + assert True + +# --------------------------------------------------------------------------- +# Contribution.from_input_model +# --------------------------------------------------------------------------- + + +class TestContributionFromInputModel: + def test_is_public_forced_to_false(self): + contrib_in = _make_contribution_in() + contribution = Contribution.from_input_model(contrib_in) + assert contribution.is_public is False + + def test_is_public_false_even_if_input_had_is_public(self): + # ContributionIn (ContributionBase) has no is_public field, but we ensure + # from_input_model always sets it to False on the resulting Contribution. + contrib_in = _make_contribution_in() + contribution = Contribution.from_input_model(contrib_in) + assert contribution.is_public is False + + def test_fields_carried_over(self): + contrib_in = _make_contribution_in(project="my-project", formula="SiO2") + contribution = Contribution.from_input_model(contrib_in) + assert contribution.project == "my-project" + assert contribution.formula == "SiO2" + + def test_data_carried_over(self): + data = {"key": "value"} + contrib_in = _make_contribution_in(data=data) + contribution = Contribution.from_input_model(contrib_in) + assert contribution.data == data + + +# --------------------------------------------------------------------------- +# ContributionOut — optional fields +# --------------------------------------------------------------------------- + + +class TestContributionOut: + def test_all_fields_optional(self): + out = ContributionOut() + assert out.id is None + assert out.project is None + assert out.formula is None + assert out.is_public is None + assert out.data is None + + def test_partial_population(self): + out = ContributionOut(project="mp-proj", formula="Li2O") + assert out.project == "mp-proj" + assert out.formula == "Li2O" + assert out.identifier is None + + def test_is_public_field(self): + out = ContributionOut(is_public=True) + assert out.is_public is True + + def test_data_field(self): + data = {"energy": -3.5} + out = ContributionOut(data=data) + assert out.data == data + + +# --------------------------------------------------------------------------- +# ContributionPatch — sparse update model +# --------------------------------------------------------------------------- + + +class TestContributionPatch: + def test_all_fields_optional(self): + patch = ContributionPatch() + assert patch.project is None + assert patch.identifier is None + assert patch.formula is None + assert patch.data is None + + def test_partial_patch(self): + patch = ContributionPatch(formula="Li2O", needs_build=False) + assert patch.formula == "Li2O" + assert patch.needs_build is False + assert patch.project is None + + def test_data_can_be_set(self): + patch = ContributionPatch(data={"new_key": 42}) + assert patch.data == {"new_key": 42} + + +# --------------------------------------------------------------------------- +# MongoDbContributionRepository._build_scope (pure logic, no DB needed) +# --------------------------------------------------------------------------- + +_ADMIN = User(username="google:admin@example.com", groups=frozenset({"admin"})) +_ALICE = User(username="google:alice@example.com", groups=frozenset({"mp-team"})) +_ANON = User() + + +class TestContributionRepoScope: + def test_admin_scope_is_empty(self): + assert MongoDbContributionRepository._build_scope(_ADMIN) == {} + + def test_anon_scope_has_or_clause(self): + scope = MongoDbContributionRepository._build_scope(_ANON) + assert "$or" in scope + + def test_anon_scope_includes_is_public_true(self): + ors = MongoDbContributionRepository._build_scope(_ANON)["$or"] + assert any(c == {"is_public": True} for c in ors) + + def test_anon_scope_has_no_group_id_clause(self): + ors = MongoDbContributionRepository._build_scope(_ANON)["$or"] + assert not any("_id" in c for c in ors) + + def test_authed_user_scope_includes_is_public(self): + ors = MongoDbContributionRepository._build_scope(_ALICE)["$or"] + assert any(c == {"is_public": True} for c in ors) + + def test_authed_user_with_groups_has_group_id_clause(self): + user = User(username="u@example.com", groups=frozenset({"g1", "g2"})) + ors = MongoDbContributionRepository._build_scope(user)["$or"] + group_clause = next((c for c in ors if "project" in c), None) + assert group_clause is not None + assert set(group_clause["project"]["$in"]) == {"g1", "g2"} + + def test_authed_user_no_groups_has_no_group_id_clause(self): + user = User(username="u@example.com", groups=frozenset()) + ors = MongoDbContributionRepository._build_scope(user)["$or"] + assert not any("_id" in c for c in ors) + + +# --------------------------------------------------------------------------- +# ContributionFilter.convert_str_to_oid +# --------------------------------------------------------------------------- + + +class TestContributionFilterIdValidator: + def test_empty_filter_id_is_none(self): + assert ContributionFilter().id is None + + def test_str_converted_to_object_id(self): + oid = PydanticObjectId() + filter = ContributionFilter(id=str(oid)) + assert isinstance(filter.id, PydanticObjectId) + assert filter.id == oid + + def test_object_id_passthrough(self): + oid = PydanticObjectId() + assert ContributionFilter(id=oid).id == oid + + # RED: a malformed id currently leaks bson.errors.InvalidId (not a + # ValueError subclass), which the exception handlers don't map — so + # `DELETE /contributions/{bad-id}` would 500 instead of 422. Intended + # behavior is a controlled validation error, matching how + # MongoDbRepository._convert_object_id handles the same input. + def test_malformed_id_raises_validation_error(self): + with pytest.raises(ValidationError): + ContributionFilter(id="not-an-object-id") diff --git a/mpcontribs-api/tests/unit/domains/test_projects_models.py b/mpcontribs-api/tests/unit/domains/test_projects_models.py new file mode 100644 index 000000000..b874091e7 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_projects_models.py @@ -0,0 +1,256 @@ +import pytest +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.domains.projects.models import ( + Column, + Project, + ProjectIn, + ProjectOut, + ProjectPatch, + Reference, + Stats, +) + +# --------------------------------------------------------------------------- +# Column +# --------------------------------------------------------------------------- + + +class TestColumn: + def test_path_only(self): + col = Column(path="data.band_gap") + assert col.path == "data.band_gap" + assert col.min is None + assert col.max is None + assert col.unit is None + + def test_full_column(self): + col = Column(path="data.band_gap", min=0.0, max=10.0, unit="eV") + assert col.min == 0.0 + assert col.max == 10.0 + assert col.unit == "eV" + + def test_segments_single(self): + col = Column(path="energy") + assert col.segments == ("energy",) + + def test_segments_dotted(self): + col = Column(path="data.band_gap.value") + assert col.segments == ("data", "band_gap", "value") + + def test_segments_two_level(self): + col = Column(path="data.volume") + assert col.segments == ("data", "volume") + + +# --------------------------------------------------------------------------- +# Stats +# --------------------------------------------------------------------------- + + +class TestStats: + def test_valid_stats(self): + stats = Stats(columns=3, contributions=100, tables=5, structures=10, attachments=2, size=1024.5) + assert stats.columns == 3 + assert stats.contributions == 100 + assert stats.size == 1024.5 + + def test_zero_values_allowed(self): + stats = Stats(columns=0, contributions=0, tables=0, structures=0, attachments=0, size=0.0) + assert stats.contributions == 0 + + def test_missing_field_raises(self): + with pytest.raises(PydanticValidationError): + Stats(columns=1, contributions=2, tables=3, structures=4, attachments=5) # missing size + + +# --------------------------------------------------------------------------- +# Reference +# --------------------------------------------------------------------------- + + +class TestReference: + def test_valid_reference(self): + ref = Reference(label="Paper", url="https://doi.org/10.1000/xyz") + assert ref.label == "Paper" + assert str(ref.url).startswith("https://doi.org") + + def test_invalid_url_raises(self): + with pytest.raises(PydanticValidationError): + Reference(label="Paper", url="not-a-url") + + def test_missing_label_raises(self): + with pytest.raises(PydanticValidationError): + Reference(url="https://example.com") # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# ProjectOut — optional fields, extra ignored +# --------------------------------------------------------------------------- + + +class TestProjectOut: + def test_all_fields_optional(self): + out = ProjectOut() + assert out.id is None + assert out.title is None + assert out.authors is None + assert out.stats is None + + def test_extra_fields_ignored(self): + out = ProjectOut(title="My Project", _unknown_field="ignored") # type: ignore[call-arg] + assert out.title == "My Project" + + def test_with_stats(self): + stats = Stats(columns=1, contributions=2, tables=0, structures=0, attachments=0, size=512.0) + out = ProjectOut(title="My Project", stats=stats) + assert out.stats is not None + assert out.stats.contributions == 2 + + def test_boolean_fields(self): + out = ProjectOut(is_public=True, is_approved=False) + assert out.is_public is True + assert out.is_approved is False + + def test_license_values(self): + out_cca4 = ProjectOut(license="CCA4") + out_ccpd = ProjectOut(license="CCPD") + assert out_cca4.license == "CCA4" + assert out_ccpd.license == "CCPD" + + def test_invalid_license_raises(self): + with pytest.raises(PydanticValidationError): + ProjectOut(license="MIT") + + +# --------------------------------------------------------------------------- +# ProjectOut — field projection helpers (inherited from SparseFieldsModel) +# --------------------------------------------------------------------------- + + +class TestProjectOutProjection: + def test_parse_fields_none_returns_none(self): + assert ProjectOut.parse_fields(None) is None + + def test_parse_fields_valid_field(self): + result = ProjectOut.parse_fields(["title"]) + assert result is not None + assert "title" in result + + def test_parse_fields_multiple_fields(self): + result = ProjectOut.parse_fields(["title", "authors", "is_public"]) + assert result is not None + assert "title" in result + assert "authors" in result + assert "is_public" in result + + def test_parse_fields_unknown_raises(self): + from mpcontribs_api.exceptions import ValidationError as AppValidationError + + with pytest.raises(AppValidationError): + ProjectOut.parse_fields(["nonexistent_field"]) + + def test_projection_none_returns_self(self): + assert ProjectOut.projection(None) is ProjectOut + + def test_projection_with_fields(self): + fields = ProjectOut.parse_fields(["title", "authors"]) + projected = ProjectOut.projection(fields) + assert projected is not ProjectOut + assert hasattr(projected.Settings, "projection") + + +# --------------------------------------------------------------------------- +# ProjectPatch +# --------------------------------------------------------------------------- + + +class TestProjectPatch: + def test_all_optional(self): + patch = ProjectPatch() + assert patch.title is None + assert patch.authors is None + assert patch.owner is None + + def test_partial_update(self): + patch = ProjectPatch(title="Updated Title", is_public=True) + assert patch.title == "Updated Title" + assert patch.is_public is True + + def test_invalid_short_str_for_title_raises(self): + with pytest.raises(PydanticValidationError): + ProjectPatch(title="ab") # too short + + def test_default_lists_are_empty(self): + patch = ProjectPatch() + assert patch.references == [] + assert patch.columns == [] + + def test_invalid_license_raises(self): + with pytest.raises(PydanticValidationError): + ProjectPatch(license="GPL") + + +# --------------------------------------------------------------------------- +# Project.from_input_model (smoke-test via ProjectIn) +# --------------------------------------------------------------------------- + + +VALID_STATS = Stats(columns=0, contributions=0, tables=0, structures=0, attachments=0, size=0.0) + + +class TestProjectFromInputModel: + def _make_input(self, **overrides): + defaults = { + "_id": "test-proj", + "title": "Test Project", + "authors": "Alice, Bob", + "description": "A test project", + "owner": "google:alice@example.com", + "unique_identifiers": True, + "stats": VALID_STATS, + } + defaults.update(overrides) + return ProjectIn(**defaults) + + def test_from_input_model_creates_project(self): + project_in = self._make_input() + project = Project.from_input_model(project_in) + assert isinstance(project, Project) + assert project.id == "test-proj" + assert project.title == "Test Project" + + def test_from_input_model_preserves_owner(self): + project_in = self._make_input(owner="github:bob@github.com") + project = Project.from_input_model(project_in) + assert project.owner == "github:bob@github.com" + + def test_from_input_model_defaults(self): + project_in = self._make_input() + project = Project.from_input_model(project_in) + assert project.is_public is False + assert project.is_approved is False + assert project.references == [] + assert project.columns == [] + + +# --------------------------------------------------------------------------- +# Project.decode_cursor (string-id override) +# --------------------------------------------------------------------------- + + +class TestProjectDecodeCursor: + def test_round_trips_string_id(self): + from mpcontribs_api.pagination import encode_cursor + + assert Project.decode_cursor(encode_cursor("my-project")) == "my-project" + + def test_returns_plain_str_not_object_id(self): + from mpcontribs_api.pagination import encode_cursor + + decoded = Project.decode_cursor(encode_cursor("solar-cells")) + assert type(decoded) is str + + def test_malformed_cursor_raises_value_error(self): + with pytest.raises(ValueError): + Project.decode_cursor("!!!not-base64!!!") diff --git a/mpcontribs-api/tests/unit/domains/test_shared_bulk.py b/mpcontribs-api/tests/unit/domains/test_shared_bulk.py new file mode 100644 index 000000000..32b900839 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_shared_bulk.py @@ -0,0 +1,120 @@ +from beanie import PydanticObjectId + +from mpcontribs_api.domains._shared.bulk import ( + BulkDeleteSummary, + BulkFailure, + BulkWriteSummary, + bulk_failure_from_exception, +) +from mpcontribs_api.exceptions import ConflictError, NotFoundError, ValidationError + +# --------------------------------------------------------------------------- +# BulkFailure +# --------------------------------------------------------------------------- + + +class TestBulkFailure: + def test_required_fields(self): + failure = BulkFailure(index=3, error_code="conflict", message="duplicate") + assert failure.index == 3 + assert failure.error_code == "conflict" + assert failure.message == "duplicate" + + def test_identifier_defaults_to_none(self): + failure = BulkFailure(index=0, error_code="x", message="y") + assert failure.identifier is None + + def test_identifier_carries_arbitrary_dict(self): + identifier = {"project": "proj", "identifier": "mp-1"} + failure = BulkFailure(index=0, identifier=identifier, error_code="x", message="y") + assert failure.identifier == identifier + + +# --------------------------------------------------------------------------- +# BulkWriteSummary +# --------------------------------------------------------------------------- + + +class TestBulkWriteSummary: + def test_fields(self): + summary = BulkWriteSummary[int](total=3, succeeded=[1, 2], failed=[]) + assert summary.total == 3 + assert summary.succeeded == [1, 2] + assert summary.failed == [] + + def test_failed_items_typed(self): + failure = BulkFailure(index=1, error_code="validation_error", message="bad") + summary = BulkWriteSummary[int](total=2, succeeded=[1], failed=[failure]) + assert summary.failed[0].index == 1 + + def test_empty_summary(self): + summary = BulkWriteSummary[int](total=0, succeeded=[], failed=[]) + assert summary.total == 0 + + def test_serialization_shape(self): + failure = BulkFailure(index=0, error_code="conflict", message="dup") + summary = BulkWriteSummary[int](total=1, succeeded=[], failed=[failure]) + dumped = summary.model_dump() + assert dumped == { + "total": 1, + "succeeded": [], + "failed": [{"index": 0, "identifier": None, "error_code": "conflict", "message": "dup"}], + } + + +# --------------------------------------------------------------------------- +# BulkDeleteSummary +# --------------------------------------------------------------------------- + + +class TestBulkDeleteSummary: + def test_fields(self): + summary = BulkDeleteSummary(num_deleted=5, num_children_deleted=12) + assert summary.num_deleted == 5 + assert summary.num_children_deleted == 12 + + def test_zero_counts(self): + summary = BulkDeleteSummary(num_deleted=0, num_children_deleted=0) + assert summary.model_dump() == {"num_deleted": 0, "num_children_deleted": 0} + + +# --------------------------------------------------------------------------- +# bulk_failure_from_exception +# --------------------------------------------------------------------------- + + +class TestBulkFailureFromException: + def test_app_error_uses_its_error_code_and_message(self): + failure = bulk_failure_from_exception(2, None, ConflictError("already exists")) + assert failure.index == 2 + assert failure.error_code == "conflict" + assert failure.message == "already exists" + + def test_not_found_error(self): + failure = bulk_failure_from_exception(0, None, NotFoundError("gone")) + assert failure.error_code == "not_found" + + def test_validation_error(self): + failure = bulk_failure_from_exception(0, None, ValidationError("bad payload")) + assert failure.error_code == "validation_error" + assert failure.message == "bad payload" + + def test_app_error_default_message_is_class_name(self): + failure = bulk_failure_from_exception(0, None, ConflictError()) + assert failure.message == "ConflictError" + + def test_generic_exception_collapses_to_internal_error(self): + failure = bulk_failure_from_exception(1, None, RuntimeError("secret traceback details")) + assert failure.error_code == "internal_error" + + def test_generic_exception_message_is_class_name_only(self): + # Internals must not leak to the client; only the exception class name survives. + failure = bulk_failure_from_exception(1, None, RuntimeError("secret traceback details")) + assert failure.message == "RuntimeError" + assert "secret" not in failure.message + + def test_identifier_threaded_through(self): + identifier = {"id": str(PydanticObjectId())} + failure = bulk_failure_from_exception(4, identifier, ValueError("x")) + assert failure.identifier == identifier + assert failure.index == 4 diff --git a/mpcontribs-api/tests/unit/domains/test_shared_models.py b/mpcontribs-api/tests/unit/domains/test_shared_models.py new file mode 100644 index 000000000..2ba8c3c9d --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_shared_models.py @@ -0,0 +1,112 @@ +import pytest +from beanie import PydanticObjectId +from pymongo.results import DeleteResult + +from mpcontribs_api.domains._shared.models import ( + BaseDocumentWithInput, + DeleteResponse, + DocumentOut, +) +from mpcontribs_api.domains.attachments.models import Attachment, AttachmentIn +from mpcontribs_api.pagination import encode_cursor + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _attachment_in(**overrides) -> AttachmentIn: + payload = { + "name": "data.csv.gz", + "mime": "application/gzip", + "content": 1, + } + payload.update(overrides) + return AttachmentIn(**payload) + + +class _OidOut(DocumentOut[PydanticObjectId]): + name: str | None = None + + +# --------------------------------------------------------------------------- +# Component.from_input (server-assigned id, computed md5) +# --------------------------------------------------------------------------- + + +class TestComponentFromInput: + def test_returns_document_class_instance(self): + doc = Attachment.from_input(_attachment_in()) + assert isinstance(doc, Attachment) + + def test_content_carried_id_assigned_md5_computed(self): + doc = Attachment.from_input(_attachment_in(name="x.gz")) + assert doc.name == "x.gz" + assert doc.mime == "application/gzip" + assert doc.content == 1 + assert doc.id is not None + assert len(doc.md5) == 32 + + +# --------------------------------------------------------------------------- +# BaseDocumentWithInput.decode_cursor +# --------------------------------------------------------------------------- + + +class TestDecodeCursor: + def test_round_trips_object_id(self): + oid = PydanticObjectId() + decoded = BaseDocumentWithInput.decode_cursor(encode_cursor(str(oid))) + assert decoded == oid + + def test_returns_pydantic_object_id(self): + cursor = encode_cursor(str(PydanticObjectId())) + assert isinstance(BaseDocumentWithInput.decode_cursor(cursor), PydanticObjectId) + + def test_malformed_base64_raises_value_error(self): + with pytest.raises(ValueError): + BaseDocumentWithInput.decode_cursor("!!!not-base64!!!") + + def test_callable_off_concrete_subclass(self): + oid = PydanticObjectId() + assert Attachment.decode_cursor(encode_cursor(str(oid))) == oid + + +# --------------------------------------------------------------------------- +# DocumentOut +# --------------------------------------------------------------------------- + + +class TestDocumentOut: + def test_id_defaults_to_none(self): + assert _OidOut().id is None + + def test_populates_from_mongo_alias(self): + oid = PydanticObjectId() + out = _OidOut.model_validate({"_id": oid}) + assert out.id == oid + + def test_serializes_under_id_not_underscore_id(self): + oid = PydanticObjectId() + dumped = _OidOut.model_validate({"_id": oid, "name": "n"}).model_dump(by_alias=True) + assert dumped["id"] == oid + assert "_id" not in dumped + + +# --------------------------------------------------------------------------- +# DeleteResponse.from_delete_result +# --------------------------------------------------------------------------- + + +class TestDeleteResponse: + def test_from_delete_result(self): + result = DeleteResult({"n": 3}, acknowledged=True) + assert DeleteResponse.from_delete_result(result).num_deleted == 3 + + def test_zero_deleted(self): + result = DeleteResult({"n": 0}, acknowledged=True) + assert DeleteResponse.from_delete_result(result).num_deleted == 0 + + def test_serialization_shape(self): + result = DeleteResult({"n": 7}, acknowledged=True) + assert DeleteResponse.from_delete_result(result).model_dump() == {"num_deleted": 7} diff --git a/mpcontribs-api/tests/unit/domains/test_shared_repository_download.py b/mpcontribs-api/tests/unit/domains/test_shared_repository_download.py new file mode 100644 index 000000000..b32417ef3 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_shared_repository_download.py @@ -0,0 +1,349 @@ +import csv +import gzip +import io +import json +from collections.abc import AsyncIterable, AsyncIterator +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from beanie import PydanticObjectId +from pydantic import BaseModel + +from mpcontribs_api.authz import User +from mpcontribs_api.domains._shared.repository import MongoDbRepository +from mpcontribs_api.domains._shared.types import DownloadFormat, ShortMimeFormat + +# --------------------------------------------------------------------------- +# Test doubles +# --------------------------------------------------------------------------- + + +class _Out(BaseModel): + """Minimal output model with scalar fields.""" + + a: int + b: str + + +class _OutWithData(BaseModel): + """Output model whose ``data`` column holds a nested dict (CSV edge case).""" + + name: str + data: dict + + +class _FakeQuery: + """Async-iterable stand-in for a Beanie find() query.""" + + def __init__(self, rows: list[Any]) -> None: + self._rows = rows + + async def __aiter__(self) -> AsyncIterator[Any]: + for row in self._rows: + yield row + + +class _FakeFilter: + """Stand-in for a fastapi-filter Filter. + + ``filter()`` ignores the base query and returns a fake query over the seeded + rows; ``sort()`` is a passthrough; ``model_dump()`` returns the configured + payload (used by ``download`` when building the cache key). + """ + + def __init__(self, rows: list[Any], dump: dict[str, Any] | None = None) -> None: + self._rows = rows + self._dump = {} if dump is None else dump + + def filter(self, _base: Any) -> _FakeQuery: + return _FakeQuery(self._rows) + + def sort(self, query: _FakeQuery) -> _FakeQuery: + return query + + def model_dump(self) -> dict[str, Any]: + return self._dump + + +class _FakeRepo(MongoDbRepository): + """Concrete repository binding just enough to exercise the shared download core.""" + + document_model = MagicMock() + out_model = _Out + + @staticmethod + def _build_scope(user: User) -> dict[str, Any]: + return {} + + +def _repo(out_model: type[BaseModel] = _Out) -> _FakeRepo: + repo = _FakeRepo(User()) + repo.out_model = out_model # type: ignore[assignment] + repo.document_model = MagicMock() # type: ignore[assignment] + return repo + + +async def _aiter(items: list[Any]) -> AsyncIterator[Any]: + for item in items: + yield item + + +async def _collect(stream: AsyncIterable[bytes]) -> bytes: + chunks: list[bytes] = [] + async for chunk in stream: + chunks.append(chunk) + return b"".join(chunks) + + +# =========================================================================== +# _serialize_jsonl +# =========================================================================== + + +class TestSerializeJsonl: + async def test_one_line_per_row(self): + rows = [_Out(a=1, b="x"), _Out(a=2, b="y")] + out = await _collect(MongoDbRepository._serialize_jsonl(_aiter(rows))) + assert out.count(b"\n") == 2 + + async def test_each_line_round_trips_to_row(self): + rows = [_Out(a=1, b="x"), _Out(a=2, b="y")] + out = await _collect(MongoDbRepository._serialize_jsonl(_aiter(rows))) + parsed = [json.loads(line) for line in out.splitlines()] + assert parsed == [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}] + + async def test_every_line_terminated_with_newline(self): + rows = [_Out(a=1, b="x"), _Out(a=2, b="y")] + out = await _collect(MongoDbRepository._serialize_jsonl(_aiter(rows))) + assert out.endswith(b"\n") + + async def test_empty_input_yields_nothing(self): + out = await _collect(MongoDbRepository._serialize_jsonl(_aiter([]))) + assert out == b"" + + async def test_unicode_payload_preserved(self): + rows = [_Out(a=1, b="café—ü")] + out = await _collect(MongoDbRepository._serialize_jsonl(_aiter(rows))) + assert json.loads(out)["b"] == "café—ü" + + +# =========================================================================== +# _serialize_csv +# =========================================================================== + + +def _parse_csv(raw: bytes) -> list[dict[str, str]]: + return list(csv.DictReader(io.StringIO(raw.decode()))) + + +class TestSerializeCsv: + async def test_header_written_once(self): + rows = [_Out(a=1, b="x"), _Out(a=2, b="y")] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), None)) + # Header appears exactly once even across multiple rows. + assert raw.decode().count("a,b") == 1 + + async def test_columns_default_to_first_row_keys_when_no_fields(self): + rows = [_Out(a=1, b="x")] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), None)) + reader = csv.reader(io.StringIO(raw.decode())) + assert next(reader) == ["a", "b"] + + async def test_columns_follow_sorted_fields_when_given(self): + rows = [_Out(a=1, b="x")] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), frozenset({"b", "a"}))) + reader = csv.reader(io.StringIO(raw.decode())) + assert next(reader) == ["a", "b"] + + async def test_extra_fields_are_ignored(self): + # 'b' is not in the requested field set -> dropped from output. + rows = [_Out(a=1, b="x")] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), frozenset({"a"}))) + parsed = _parse_csv(raw) + assert parsed == [{"a": "1"}] + + async def test_all_rows_emitted(self): + rows = [_Out(a=i, b=str(i)) for i in range(5)] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), None)) + assert len(_parse_csv(raw)) == 5 + + async def test_no_row_bleed_between_chunks(self): + # Each yielded chunk after the header must contain exactly one row, proving + # the shared StringIO buffer is truncated between iterations. + rows = [_Out(a=1, b="x"), _Out(a=2, b="y")] + chunks = [c async for c in MongoDbRepository._serialize_csv(_aiter(rows), None)] + # First chunk: header + row 1; subsequent chunks: one row each. + assert b"2,y" not in chunks[0] + + async def test_empty_input_yields_no_bytes(self): + raw = await _collect(MongoDbRepository._serialize_csv(_aiter([]), None)) + assert raw == b"" + + async def test_dict_value_serialized_as_json(self): + """Dict-valued columns are emitted as JSON, not Python repr. + + ``model_dump(mode="json")`` leaves nested dicts as dict objects; the serializer + JSON-encodes them so the cell is valid JSON a consumer can round-trip (rather + than ``str(dict)``, the single-quoted Python repr). + """ + rows = [_OutWithData(name="r1", data={"k": "v", "n": 1})] + raw = await _collect(MongoDbRepository._serialize_csv(_aiter(rows), None)) + cell = _parse_csv(raw)[0]["data"] + assert json.loads(cell) == {"k": "v", "n": 1} + + +# =========================================================================== +# _get_serializer +# =========================================================================== + + +class TestGetSerializer: + @pytest.mark.parametrize("format", list(DownloadFormat)) + async def test_every_format_dispatches_to_a_usable_serializer(self, format: DownloadFormat): + """Every ``DownloadFormat`` member maps to a serializer of the expected shape. + + ``_get_serializer`` has no default case, so an unhandled member would silently + return ``None``. Parametrizing over every member guarantees the match stays + exhaustive: adding a format without a matching case fails here rather than at + request time. The returned object is uniformly a callable taking the row stream + and yielding ``bytes``, regardless of which format produced it. + """ + repo = _repo() + serializer = repo._get_serializer(format, None) + assert callable(serializer) + raw = await _collect(serializer(_aiter([_Out(a=1, b="x")]))) + assert isinstance(raw, bytes) and raw + + async def test_jsonl_dispatches_to_jsonl_output(self): + repo = _repo() + serializer = repo._get_serializer(DownloadFormat.JSONL, None) + raw = await _collect(serializer(_aiter([_Out(a=1, b="x")]))) + assert json.loads(raw) == {"a": 1, "b": "x"} + + async def test_csv_dispatches_to_csv_output_and_threads_fields(self): + # The fields passed to _get_serializer must reach the CSV serializer: 'b' is + # dropped because only 'a' was requested. + repo = _repo() + serializer = repo._get_serializer(DownloadFormat.CSV, frozenset({"a"})) + raw = await _collect(serializer(_aiter([_Out(a=1, b="x")]))) + assert _parse_csv(raw) == [{"a": "1"}] + + +# =========================================================================== +# _hash_payload +# =========================================================================== + + +class TestHashPayload: + def test_is_deterministic(self): + repo = _repo() + payload = {"format": "jsonl", "fields": ["a", "b"]} + assert repo._hash_payload(payload) == repo._hash_payload(payload) + + def test_independent_of_key_order(self): + repo = _repo() + assert repo._hash_payload({"a": 1, "b": 2}) == repo._hash_payload({"b": 2, "a": 1}) + + def test_sensitive_to_value_changes(self): + repo = _repo() + assert repo._hash_payload({"a": 1}) != repo._hash_payload({"a": 2}) + + def test_returns_sha256_hex(self): + repo = _repo() + digest = repo._hash_payload({"a": 1}) + assert len(digest) == 64 + assert all(c in "0123456789abcdef" for c in digest) + + def test_object_id_filter_is_hashable(self): + """Filters carrying an ObjectId hash without raising. + + ``download`` hashes ``filter.model_dump()`` which, for an ``id__in`` filter, + contains PydanticObjectId values. ``_hash_payload`` passes ``default=str`` so + these stringify into a stable key instead of raising ``TypeError``. + """ + repo = _repo() + payload = {"filter": {"id__in": [PydanticObjectId(), PydanticObjectId()]}} + digest = repo._hash_payload(payload) + assert len(digest) == 64 + + def test_datetime_filter_is_hashable(self): + """Filters carrying a datetime hash without raising (see above).""" + repo = _repo() + payload = {"filter": {"created__gte": datetime(2024, 1, 1, tzinfo=timezone.utc)}} + digest = repo._hash_payload(payload) + assert len(digest) == 64 + + +# =========================================================================== +# download (end-to-end: query -> serialize -> gzip stream) +# =========================================================================== + + +class TestDownload: + async def test_jsonl_stream_decompresses_to_rows(self): + """The gzip stream decompresses cleanly to the JSONL payload. + + ``download`` flushes the zlib gzip compressor after the final chunk so the + trailing buffered bytes and the gzip footer (CRC32 + ISIZE) are emitted. + Regression guard: without the flush the member is truncated and + ``gzip.decompress`` raises. + """ + repo = _repo(_Out) + filter = _FakeFilter(rows=[SimpleNamespace(a=1, b="x"), SimpleNamespace(a=2, b="y")]) + stream = repo.download( + format=DownloadFormat.JSONL, + short_mime=ShortMimeFormat.GZ, + ignore_cache=True, + filter=filter, # type: ignore[arg-type] + fields=None, + s3=MagicMock(), + bucket_name="test-bucket", + key_name="test-key", + ) + compressed = await _collect(stream) + decompressed = gzip.decompress(compressed) + parsed = [json.loads(line) for line in decompressed.splitlines()] + assert parsed == [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}] + + async def test_csv_stream_decompresses_to_rows(self): + """Same gzip flush guard, exercised through the CSV serializer.""" + repo = _repo(_Out) + filter = _FakeFilter(rows=[SimpleNamespace(a=1, b="x"), SimpleNamespace(a=2, b="y")]) + stream = repo.download( + format=DownloadFormat.CSV, + short_mime=ShortMimeFormat.GZ, + ignore_cache=True, + filter=filter, # type: ignore[arg-type] + fields=frozenset({"a", "b"}), + s3=MagicMock(), + bucket_name="test-bucket", + key_name="test-key", + ) + compressed = await _collect(stream) + decompressed = gzip.decompress(compressed) + assert _parse_csv(decompressed) == [{"a": "1", "b": "x"}, {"a": "2", "b": "y"}] + + async def test_empty_result_is_valid_empty_gzip(self): + """A download with no matching rows yields zero bytes, which gzip treats as empty. + + Unlike the non-empty cases (which hit the missing-``flush()`` bug), an empty + result never enters the compress loop, so the stream is genuinely empty and + ``gzip.decompress(b"")`` returns ``b""``. Guards that empty downloads stay valid. + """ + repo = _repo(_Out) + filter = _FakeFilter(rows=[]) + stream = repo.download( + format=DownloadFormat.JSONL, + short_mime=ShortMimeFormat.GZ, + ignore_cache=True, + filter=filter, # type: ignore[arg-type] + fields=None, + s3=MagicMock(), + bucket_name="test-bucket", + key_name="test-key", + ) + compressed = await _collect(stream) + assert gzip.decompress(compressed) == b"" diff --git a/mpcontribs-api/tests/unit/domains/test_structures_models.py b/mpcontribs-api/tests/unit/domains/test_structures_models.py new file mode 100644 index 000000000..213724b87 --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_structures_models.py @@ -0,0 +1,240 @@ +import polars as pl +import pytest +from beanie import PydanticObjectId +from pydantic import ValidationError as PydanticValidationError +from pymatgen.core import Element + +from mpcontribs_api.domains.structures.models import ( + Lattice, + SiteProperties, + Species, + Structure, + StructureFilter, + StructureIn, + StructureOut, + StructurePatch, +) +from mpcontribs_api.exceptions import ValidationError as AppValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _lattice_payload(**overrides) -> dict: + payload = { + "matrix": {"x": [1.0, 0.0, 0.0], "y": [0.0, 1.0, 0.0], "z": [0.0, 0.0, 1.0]}, + "pbc": [True, True, True], + "a": 1.0, + "b": 1.0, + "c": 1.0, + "alpha": 90.0, + "beta": 90.0, + "gamma": 90.0, + "volume": 1.0, + } + payload.update(overrides) + return payload + + +def _site_payload(**overrides) -> dict: + payload = { + "species": [{"element": "Fe", "occu": 1}], + "abc": [0.0, 0.0, 0.0], + "properties": {"magmom": 2.2}, + "label": "Fe", + "xyz": [0.0, 0.0, 0.0], + } + payload.update(overrides) + return payload + + +def _structure_payload(**overrides) -> dict: + payload = { + "_id": PydanticObjectId(), + "name": "Fe2O3", + "md5": "f" * 32, + "lattice": _lattice_payload(), + "sites": [_site_payload()], + "charge": 0.0, + "cif": "data_Fe2O3\n_cell_length_a 1.0\n", + } + payload.update(overrides) + return payload + + +# --------------------------------------------------------------------------- +# Leaf models +# --------------------------------------------------------------------------- + + +class TestSiteProperties: + def test_valid(self): + assert SiteProperties(magmom=1.5).magmom == 1.5 + + def test_missing_magmom_raises(self): + with pytest.raises(PydanticValidationError): + SiteProperties() + + +class TestSpecies: + def test_element_coerced_from_symbol(self): + species = Species(element="Fe", occu=1) + assert species.element is Element.Fe + assert species.occu == 1 + + def test_invalid_symbol_raises(self): + with pytest.raises(PydanticValidationError): + Species(element="Xx", occu=1) + + def test_element_enum_passthrough(self): + assert Species(element=Element.O, occu=2).element is Element.O + + +class TestLattice: + def test_valid_construction(self): + lattice = Lattice(**_lattice_payload()) + assert isinstance(lattice.matrix, pl.DataFrame) + assert lattice.pbc == [True, True, True] + assert lattice.volume == 1.0 + + def test_matrix_coerced_from_dict(self): + lattice = Lattice(**_lattice_payload()) + assert lattice.matrix.columns == ["x", "y", "z"] + + def test_missing_angles_raise(self): + payload = _lattice_payload() + del payload["alpha"] + with pytest.raises(PydanticValidationError): + Lattice(**payload) + + +# --------------------------------------------------------------------------- +# Structure / StructureIn +# --------------------------------------------------------------------------- + + +class TestStructure: + def test_valid_construction(self): + structure = Structure(**_structure_payload()) + assert structure.name == "Fe2O3" + assert len(structure.sites) == 1 + assert structure.sites[0].species[0].element is Element.Fe + + def test_collection_name(self): + assert Structure.Settings.name == "structures" + + def test_charge_is_required_but_nullable(self): + assert Structure(**_structure_payload(charge=None)).charge is None + payload = _structure_payload() + del payload["charge"] + with pytest.raises(PydanticValidationError): + Structure(**payload) + + def test_md5_is_computed_not_taken_from_input(self): + # A client-supplied md5 is overwritten by the content-derived hash. + structure = Structure(**_structure_payload(md5="a" * 32)) + assert structure.md5 != "a" * 32 + assert len(structure.md5) == 32 + + def test_same_content_same_md5(self): + assert Structure(**_structure_payload()).md5 == Structure(**_structure_payload()).md5 + + def test_different_charge_different_md5(self): + assert Structure(**_structure_payload(charge=0.0)).md5 != Structure(**_structure_payload(charge=1.0)).md5 + + def test_cif_kept_as_raw_string(self): + structure = Structure(**_structure_payload()) + assert structure.cif.startswith("data_Fe2O3") + + def test_structure_in_has_no_id_or_md5(self): + assert "md5" not in StructureIn.model_fields + assert "id" not in StructureIn.model_fields + + def test_from_input_assigns_id_and_computes_md5(self): + sin = StructureIn( + name="Fe2O3", + lattice=_lattice_payload(), + sites=[_site_payload()], + charge=0.0, + cif="data_Fe2O3\n", + ) + doc = Structure.from_input(sin) + assert isinstance(doc, Structure) + assert doc.id is not None + assert len(doc.md5) == 32 + + +# --------------------------------------------------------------------------- +# StructureOut +# --------------------------------------------------------------------------- + + +class TestStructureOut: + def test_all_fields_optional(self): + out = StructureOut() + assert out.id is None + assert out.name is None + assert out.md5 is None + + def test_default_fields(self): + assert StructureOut.default_fields() == ["id", "name", "md5"] + + def test_default_fields_parseable(self): + # The route default must survive parse_fields without raising. + parsed = StructureOut.parse_fields(StructureOut.default_fields()) + assert parsed == frozenset({"id", "name", "md5"}) + + def test_populates_id_from_mongo_alias(self): + oid = PydanticObjectId() + assert StructureOut.model_validate({"_id": oid}).id == oid + + +# --------------------------------------------------------------------------- +# StructurePatch +# --------------------------------------------------------------------------- + + +class TestStructurePatch: + def test_all_fields_optional(self): + patch = StructurePatch() + assert patch.name is None + assert patch.lattice is None + assert patch.sites is None + + def test_partial_patch_excludes_unset(self): + patch = StructurePatch(name="renamed") + assert patch.model_dump(exclude_unset=True) == {"name": "renamed"} + + def test_lattice_patchable(self): + patch = StructurePatch(lattice=_lattice_payload()) + assert patch.lattice is not None + assert patch.lattice.volume == 1.0 + + def test_sites_is_a_list(self): + # Regression: sites must be list[Site], not a single Site. + patch = StructurePatch(sites=[_site_payload()]) + assert isinstance(patch.sites, list) + assert patch.sites[0].label == "Fe" + + +# --------------------------------------------------------------------------- +# StructureFilter +# --------------------------------------------------------------------------- + + +class TestStructureFilter: + def test_empty_filter(self): + filter = StructureFilter() + assert filter.id is None + assert filter.name__ilike is None + + def test_constants_bind_structure_model(self): + assert StructureFilter.Constants.model is Structure + + def test_md5_value_validated(self): + assert StructureFilter(md5="A" * 32).md5 == "a" * 32 + + def test_invalid_md5_raises(self): + with pytest.raises(AppValidationError): + StructureFilter(md5="short") diff --git a/mpcontribs-api/tests/unit/domains/test_tables_models.py b/mpcontribs-api/tests/unit/domains/test_tables_models.py new file mode 100644 index 000000000..624a24ace --- /dev/null +++ b/mpcontribs-api/tests/unit/domains/test_tables_models.py @@ -0,0 +1,239 @@ +import polars as pl +import pytest +from beanie import PydanticObjectId + +from mpcontribs_api.domains.tables.models import ( + Attributes, + Labels, + Table, + TableFilter, + TableIn, + TableOut, + TablePatch, +) +from mpcontribs_api.exceptions import ValidationError as AppValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ATTRS = {"title": "Band gaps", "labels": {"index": "T", "value": "gap", "variable": "doping"}} + + +def _table_doc_payload(**overrides) -> dict: + """Payload for the stored Table document — raw MongoDB shape (index/columns/data as strings).""" + payload = { + "_id": PydanticObjectId(), + "name": "bandgaps", + "attrs": ATTRS, + "index": ["100.0", "200.0"], + "columns": ["1e16", "1e17"], + "data": [["1.1", "1.2"], ["2.1", "2.2"]], + "total_data_rows": 2, + } + payload.update(overrides) + return payload + + +def _table_in_frame() -> pl.DataFrame: + # First column is the index (T), the rest are the data columns; all cells are strings. + return pl.DataFrame( + {"T": ["100.0", "200.0"], "1e16": ["1.1", "2.1"], "1e17": ["1.2", "2.2"]} + ) + + +def _table_in_payload(**overrides) -> dict: + """Payload for user input: a DataFrame (first column = index), no _id/md5/total_data_rows.""" + payload = { + "name": "bandgaps", + "attrs": ATTRS, + "data": _table_in_frame(), + } + payload.update(overrides) + return payload + + +# --------------------------------------------------------------------------- +# Leaf models +# --------------------------------------------------------------------------- + + +class TestLabels: + def test_valid(self): + labels = Labels(index="T", value="gap", variable="method") + assert labels.index == "T" + + def test_missing_field_raises(self): + with pytest.raises(Exception): + Labels(index="T", value="gap") + + +class TestAttributes: + def test_valid(self): + attrs = Attributes(**ATTRS) + assert attrs.title == "Band gaps" + assert attrs.labels.variable == "doping" + + +# --------------------------------------------------------------------------- +# Table (stored document) — md5 is server-computed +# --------------------------------------------------------------------------- + + +class TestTable: + def test_valid_construction(self): + table = Table(**_table_doc_payload()) + assert table.index == ["100.0", "200.0"] + assert table.columns == ["1e16", "1e17"] + assert table.data == [["1.1", "1.2"], ["2.1", "2.2"]] + assert table.total_data_rows == 2 + + def test_collection_name(self): + assert Table.Settings.name == "tables" + + def test_md5_is_computed_not_taken_from_input(self): + # A client-supplied md5 is ignored; the stored value is derived from content. + table = Table(**_table_doc_payload(md5="d" * 32)) + assert table.md5 != "d" * 32 + assert len(table.md5) == 32 + + def test_same_content_same_md5(self): + assert Table(**_table_doc_payload()).md5 == Table(**_table_doc_payload()).md5 + + def test_different_data_different_md5(self): + a = Table(**_table_doc_payload()) + b = Table(**_table_doc_payload(data=[["9.9", "8.8"], ["7.7", "6.6"]])) + assert a.md5 != b.md5 + + def test_attrs_part_of_md5(self): + a = Table(**_table_doc_payload()) + b = Table(**_table_doc_payload(attrs={**ATTRS, "title": "Different"})) + assert a.md5 != b.md5 + + def test_data_stored_as_string_rows(self): + table = Table(**_table_doc_payload()) + assert table.model_dump()["data"] == [["1.1", "1.2"], ["2.1", "2.2"]] + + +# --------------------------------------------------------------------------- +# TableIn — content only, no id/md5 +# --------------------------------------------------------------------------- + + +class TestTableIn: + def test_has_no_server_assigned_fields(self): + # _id, md5, and total_data_rows are all server-owned, so absent from the input contract. + assert "md5" not in TableIn.model_fields + assert "id" not in TableIn.model_fields + assert "total_data_rows" not in TableIn.model_fields + + def test_from_input_splits_frame_into_storage(self): + # First column -> index; remaining columns -> columns; cells -> row-major string data. + doc = Table.from_input(TableIn(**_table_in_payload())) + assert doc.index == ["100.0", "200.0"] + assert doc.columns == ["1e16", "1e17"] + assert doc.data == [["1.1", "1.2"], ["2.1", "2.2"]] + assert doc.total_data_rows == 2 + + def test_built_document_computes_md5(self): + doc = Table.from_input(TableIn(**_table_in_payload())) + assert len(doc.md5) == 32 + assert doc.id is not None + + +# --------------------------------------------------------------------------- +# TableFilter +# --------------------------------------------------------------------------- + + +class TestTableFilter: + def test_empty_filter(self): + filter = TableFilter() + assert filter.id is None + assert filter.name__ilike is None + + def test_constants_bind_table_model(self): + assert TableFilter.Constants.model is Table + + def test_id_serializes_to_str(self): + oid = PydanticObjectId() + assert TableFilter(id=oid).model_dump()["id"] == str(oid) + + def test_id_in_serializes_to_sorted_strs(self): + first, second = PydanticObjectId(), PydanticObjectId() + dumped = TableFilter(id__in=[second, first]).model_dump() + assert dumped["id__in"] == sorted([str(first), str(second)]) + + def test_none_ids_serialize_to_none(self): + dumped = TableFilter().model_dump() + assert dumped["id"] is None + assert dumped["id__in"] is None + + def test_md5_value_validated(self): + assert TableFilter(md5="B" * 32).md5 == "b" * 32 + + def test_invalid_md5_raises(self): + with pytest.raises(AppValidationError): + TableFilter(md5="short") + + +# --------------------------------------------------------------------------- +# TableOut / TablePatch +# --------------------------------------------------------------------------- + + +class TestTableOut: + def test_all_fields_optional(self): + out = TableOut() + assert out.id is None + assert out.data is None + assert out.attrs is None + + def test_default_fields(self): + assert TableOut.default_fields() == ["id", "name", "md5", "attrs", "total_data_rows"] + + def test_default_fields_parseable(self): + parsed = TableOut.parse_fields(TableOut.default_fields()) + assert "attrs" in parsed + + def test_content_projectable(self): + # data is on the Out model so it can be requested explicitly. + parsed = TableOut.parse_fields(["data"]) + assert "data" in parsed + + def test_data_coerced_when_present(self): + out = TableOut(data={"a": [1]}) + assert isinstance(out.data, pl.DataFrame) + + def test_reconstructs_frame_from_storage_dict(self): + # Read path: a raw Mongo dict with index/columns/data is reassembled into a DataFrame + # whose first column is the index (named by attrs.labels.index), cells preserved as strings. + out = TableOut.model_validate(_table_doc_payload(md5="a" * 32)) + assert isinstance(out.data, pl.DataFrame) + assert out.data.columns == ["T", "1e16", "1e17"] + assert out.data["T"].to_list() == ["100.0", "200.0"] + assert out.data["1e16"].to_list() == ["1.1", "2.1"] + + def test_storage_keys_not_leaked(self): + out = TableOut.model_validate(_table_doc_payload(md5="a" * 32)) + dumped = out.model_dump() + assert "index" not in dumped + assert "columns" not in dumped + + def test_projection_full_model_when_data_requested(self): + # data requested -> full model (so index/columns come back to rebuild the frame). + assert TableOut.projection(TableOut.parse_fields(["data"])) is TableOut + + def test_projection_partial_when_data_not_requested(self): + # light read -> a trimmed projection model that does not pull the storage triple. + proj = TableOut.projection(TableOut.parse_fields(["name"])) + assert proj is not TableOut + assert hasattr(proj, "Settings") + + +class TestTablePatch: + def test_name_optional(self): + assert TablePatch().name is None + + def test_partial_patch_excludes_unset(self): + assert TablePatch(name="renamed").model_dump(exclude_unset=True) == {"name": "renamed"} diff --git a/mpcontribs-api/tests/unit/test_auth.py b/mpcontribs-api/tests/unit/test_auth.py new file mode 100644 index 000000000..6651c7d81 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_auth.py @@ -0,0 +1,91 @@ +import pytest + +from mpcontribs_api.authz import ADMIN_GROUP, User + + +class TestUserIsAnonymous: + def test_no_username_is_anonymous(self): + user = User() + assert user.is_anonymous is True + + def test_username_none_is_anonymous(self): + user = User(username=None) + assert user.is_anonymous is True + + def test_with_username_not_anonymous(self): + user = User(username="google:alice@example.com") + assert user.is_anonymous is False + + +class TestUserIsAdmin: + def test_no_groups_not_admin(self): + user = User(username="google:alice@example.com") + assert user.is_admin is False + + def test_admin_group_is_admin(self): + user = User(username="google:alice@example.com", groups=frozenset({ADMIN_GROUP})) + assert user.is_admin is True + + def test_other_group_not_admin(self): + user = User(username="google:alice@example.com", groups=frozenset({"editors"})) + assert user.is_admin is False + + def test_admin_group_among_many_is_admin(self): + user = User(username="google:alice@example.com", groups=frozenset({"editors", ADMIN_GROUP, "viewers"})) + assert user.is_admin is True + + def test_anonymous_cannot_be_admin(self): + # Even if groups include admin, anonymous user (no username) is still anonymous + user = User(groups=frozenset({ADMIN_GROUP})) + assert user.is_anonymous is True + # But is_admin only checks groups, not username + assert user.is_admin is False + + +class TestUserHasRole: + def test_has_role_in_groups(self): + user = User(username="google:alice@example.com", groups=frozenset({"editors", "viewers"})) + assert user.has_role("editors") is True + + def test_has_role_not_in_groups(self): + user = User(username="google:alice@example.com", groups=frozenset({"editors"})) + assert user.has_role("viewers") is False + + def test_has_role_empty_groups(self): + user = User(username="google:alice@example.com") + assert user.has_role("editors") is False + + def test_has_role_case_sensitive(self): + user = User(username="google:alice@example.com", groups=frozenset({"Editors"})) + assert user.has_role("editors") is False + assert user.has_role("Editors") is True + + +class TestUserImmutability: + def test_user_is_frozen(self): + user = User(username="google:alice@example.com") + with pytest.raises(Exception): + user.username = "google:bob@example.com" # type: ignore[misc] + + def test_groups_default_empty_frozenset(self): + user = User() + assert user.groups == frozenset() + + def test_consumer_id_default_none(self): + user = User() + assert user.consumer_id is None + + +class TestUserConstruction: + def test_full_user(self): + user = User( + consumer_id="kong-consumer-123", + username="google:alice@example.com", + groups=frozenset({"editors", "mp-team"}), + ) + assert user.consumer_id == "kong-consumer-123" + assert user.username == "google:alice@example.com" + assert "editors" in user.groups + assert "admin" not in user.groups + assert user.is_admin is False + assert user.is_anonymous is False diff --git a/mpcontribs-api/tests/unit/test_config.py b/mpcontribs-api/tests/unit/test_config.py new file mode 100644 index 000000000..fe88f3007 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_config.py @@ -0,0 +1,182 @@ +import pytest +from pydantic import SecretStr +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.config import ( + MongoSettings, + RedisSettings, + Settings, + get_settings, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +REQUIRED_ENV = { + "MPCONTRIBS_ENVIRONMENT": "dev", + "MPCONTRIBS_MONGO__URI": "mongodb://user:pass@localhost:27017", + "MPCONTRIBS_MONGO__DB_NAME": "mpcontribs-test", + "MPCONTRIBS_REDIS__ADDRESS": "redis://localhost:6379", + "MPCONTRIBS_REDIS__URL": "redis://localhost:6379/0", + "MPCONTRIBS_MAIL_DEFAULT_SENDER": "noreply@materialsproject.org", + "MPCONTRIBS_VERSION": "0.0.0-test", +} + + +def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key, value in REQUIRED_ENV.items(): + monkeypatch.setenv(key, value) + + +def _mongo(**overrides) -> MongoSettings: + return MongoSettings(uri=SecretStr("mongodb://localhost"), db_name="db", **overrides) + + +# --------------------------------------------------------------------------- +# MongoSettings defaults +# --------------------------------------------------------------------------- + + +class TestMongoSettingsDefaults: + def test_minimal_construction(self): + settings = _mongo() + assert settings.db_name == "db" + assert settings.uri.get_secret_value() == "mongodb://localhost" + + def test_uri_is_secret(self): + settings = _mongo() + assert "mongodb://localhost" not in repr(settings.uri) + + def test_default_pool_sizes(self): + settings = _mongo() + assert settings.max_pool_size == 100 + assert settings.min_pool_size == 0 + + def test_default_admin_group(self): + assert _mongo().admin_group == "admin" + + def test_default_component_limits(self): + settings = _mongo() + assert settings.max_components_per_contribution == 500 + assert settings.component_insert_chunk_size == 100 + + def test_invalid_datetime_conversion_raises(self): + with pytest.raises(PydanticValidationError): + _mongo(datetime_conversion="not-a-mode") + + def test_missing_uri_raises(self): + with pytest.raises(PydanticValidationError): + MongoSettings(db_name="db") + + +# --------------------------------------------------------------------------- +# MongoSettings._clamp_concurrency +# --------------------------------------------------------------------------- + + +class TestClampConcurrency: + def test_default_not_clamped(self): + # max_pool_size=100 -> cap 50; default max_concurrent_transactions=16 stays. + assert _mongo().max_concurrent_transactions == 16 + + def test_clamped_to_half_pool_size(self): + settings = _mongo(max_pool_size=10, max_concurrent_transactions=16) + assert settings.max_concurrent_transactions == 5 + + def test_exactly_at_cap_not_clamped(self): + settings = _mongo(max_pool_size=32, max_concurrent_transactions=16) + assert settings.max_concurrent_transactions == 16 + + def test_pool_size_one_clamps_to_one(self): + settings = _mongo(max_pool_size=1, max_concurrent_transactions=16) + assert settings.max_concurrent_transactions == 1 + + def test_unbounded_pool_skips_clamping(self): + # max_pool_size=0 means "unlimited" to PyMongo; clamping is skipped. + settings = _mongo(max_pool_size=0, max_concurrent_transactions=999) + assert settings.max_concurrent_transactions == 999 + + def test_below_cap_unchanged(self): + settings = _mongo(max_pool_size=10, max_concurrent_transactions=2) + assert settings.max_concurrent_transactions == 2 + + +# --------------------------------------------------------------------------- +# Sub-settings secrets +# --------------------------------------------------------------------------- + + +class TestSubSettingsSecrets: + def test_redis_secrets_masked(self): + redis = RedisSettings(address=SecretStr("redis://h"), url=SecretStr("redis://h/0")) + assert redis.address.get_secret_value() == "redis://h" + assert "redis://h" not in repr(redis) + + +# --------------------------------------------------------------------------- +# Settings: env var loading +# --------------------------------------------------------------------------- + + +class TestSettingsEnvLoading: + def test_loads_from_env(self, monkeypatch): + _set_required_env(monkeypatch) + settings = Settings() + assert settings.environment == "dev" + assert settings.version == "0.0.0-test" + assert settings.mail_default_sender == "noreply@materialsproject.org" + + def test_nested_delimiter_populates_mongo(self, monkeypatch): + _set_required_env(monkeypatch) + settings = Settings() + assert settings.mongo.db_name == "mpcontribs-test" + assert settings.mongo.uri.get_secret_value() == "mongodb://user:pass@localhost:27017" + + def test_nested_delimiter_populates_and_redis(self, monkeypatch): + _set_required_env(monkeypatch) + settings = Settings() + assert settings.redis.url.get_secret_value() == "redis://localhost:6379/0" + + def test_nested_field_override(self, monkeypatch): + _set_required_env(monkeypatch) + monkeypatch.setenv("MPCONTRIBS_MONGO__MAX_POOL_SIZE", "10") + settings = Settings() + assert settings.mongo.max_pool_size == 10 + # Clamp validator runs on env-loaded values too. + assert settings.mongo.max_concurrent_transactions == 5 + + def test_invalid_environment_raises(self, monkeypatch): + _set_required_env(monkeypatch) + monkeypatch.setenv("MPCONTRIBS_ENVIRONMENT", "staging") + with pytest.raises(PydanticValidationError): + Settings() + + +# --------------------------------------------------------------------------- +# get_settings caching +# --------------------------------------------------------------------------- + + +class TestGetSettingsCaching: + @pytest.fixture(autouse=True) + def _clear_cache(self): + get_settings.cache_clear() + yield + get_settings.cache_clear() + + def test_returns_settings_instance(self, monkeypatch): + _set_required_env(monkeypatch) + assert isinstance(get_settings(), Settings) + + def test_same_instance_returned(self, monkeypatch): + _set_required_env(monkeypatch) + assert get_settings() is get_settings() + + def test_env_changes_ignored_until_cache_cleared(self, monkeypatch): + _set_required_env(monkeypatch) + first = get_settings() + monkeypatch.setenv("MPCONTRIBS_VERSION", "9.9.9") + assert get_settings().version == first.version + get_settings.cache_clear() + assert get_settings().version == "9.9.9" diff --git a/mpcontribs-api/tests/unit/test_dependencies.py b/mpcontribs-api/tests/unit/test_dependencies.py new file mode 100644 index 000000000..80c089b60 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_dependencies.py @@ -0,0 +1,121 @@ +from unittest.mock import MagicMock + +import pytest + +from mpcontribs_api.authz import User +from mpcontribs_api.dependencies import _split, get_user, require_user +from mpcontribs_api.exceptions import AuthenticationError + +# --------------------------------------------------------------------------- +# _split +# --------------------------------------------------------------------------- + + +class TestSplit: + def test_none_returns_empty_set(self): + assert _split(None) == set() + + def test_empty_string_returns_empty_set(self): + assert _split("") == set() + + def test_whitespace_only_returns_empty_set(self): + assert _split(" ") == set() + + def test_single_value(self): + assert _split("editors") == {"editors"} + + def test_multiple_values(self): + assert _split("editors,viewers,admins") == {"editors", "viewers", "admins"} + + def test_strips_whitespace(self): + assert _split(" editors , viewers ") == {"editors", "viewers"} + + def test_skips_empty_parts(self): + assert _split("editors,,viewers") == {"editors", "viewers"} + + def test_single_space_comma_separated(self): + assert _split("a, b, c") == {"a", "b", "c"} + + +# --------------------------------------------------------------------------- +# get_user — builds User from request headers +# --------------------------------------------------------------------------- + + +def _make_request(**headers: str) -> MagicMock: + """Build a mock Request whose .headers dict returns the given values.""" + request = MagicMock() + request.headers = headers + return request + + +class TestGetUser: + def test_no_headers_returns_anonymous(self): + request = _make_request() + user = get_user(request) + assert user.is_anonymous is True + + def test_explicit_anon_header_returns_anonymous(self): + request = _make_request(**{"x-anonymous-consumer": "true", "x-consumer-username": "alice"}) + user = get_user(request) + assert user.is_anonymous is True + + def test_explicit_anon_case_insensitive(self): + request = _make_request(**{"x-anonymous-consumer": "True", "x-consumer-username": "alice"}) + user = get_user(request) + assert user.is_anonymous is True + + def test_authenticated_user(self): + request = _make_request( + **{ + "x-consumer-username": "google:alice@example.com", + "x-consumer-id": "kong-123", + "x-authenticated-groups": "editors", + "x-consumer-groups": "mp-team", + } + ) + user = get_user(request) + assert user.is_anonymous is False + assert user.username == "google:alice@example.com" + assert user.consumer_id == "kong-123" + assert "editors" in user.groups + assert "mp-team" in user.groups + + def test_authenticated_user_no_groups(self): + request = _make_request(**{"x-consumer-username": "google:alice@example.com"}) + user = get_user(request) + assert user.is_anonymous is False + assert user.groups == frozenset() + + def test_groups_merged_from_both_headers(self): + request = _make_request( + **{ + "x-consumer-username": "google:alice@example.com", + "x-authenticated-groups": "a,b", + "x-consumer-groups": "c,d", + } + ) + user = get_user(request) + assert user.groups == frozenset({"a", "b", "c", "d"}) + + def test_missing_username_returns_anonymous(self): + request = _make_request(**{"x-consumer-id": "kong-123"}) + user = get_user(request) + assert user.is_anonymous is True + + +# --------------------------------------------------------------------------- +# require_user +# --------------------------------------------------------------------------- + + +class TestRequireUser: + def test_authenticated_user_passes_through(self): + authed = User(username="google:alice@example.com", groups=frozenset()) + result = require_user(authed) + assert result is authed + + def test_anonymous_raises_authentication_error(self): + anon = User() + with pytest.raises(AuthenticationError): + require_user(anon) diff --git a/mpcontribs-api/tests/unit/test_exceptions.py b/mpcontribs-api/tests/unit/test_exceptions.py new file mode 100644 index 000000000..7e167a581 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_exceptions.py @@ -0,0 +1,174 @@ +import pytest + +from mpcontribs_api.exceptions import ( + AppError, + AuthenticationError, + ConflictError, + NotFoundError, + PermissionError, + ValidationError, + _sanitize_validation_errors, + error_body, +) + + +class TestErrorBody: + def test_minimal_body(self): + body = error_body("not_found", "Resource not found") + assert body == {"error": {"code": "not_found", "message": "Resource not found"}} + + def test_body_with_context(self): + body = error_body("not_found", "Resource not found", resource_id="abc", resource_type="project") + assert body["error"]["code"] == "not_found" + assert body["error"]["message"] == "Resource not found" + assert body["error"]["detail"] == {"resource_id": "abc", "resource_type": "project"} + + def test_no_detail_key_without_context(self): + body = error_body("conflict", "Duplicate id") + assert "detail" not in body["error"] + + def test_detail_present_with_context(self): + body = error_body("conflict", "Duplicate id", existing_id="xyz") + assert "detail" in body["error"] + + +class TestAppError: + def test_default_status_and_code(self): + err = AppError() + assert err.status_code == 500 + assert err.error_code == "internal_error" + + def test_default_message_is_class_name(self): + err = AppError() + assert err.message == "AppError" + + def test_custom_message(self): + err = AppError("something went wrong") + assert err.message == "something went wrong" + + def test_context_stored(self): + err = AppError("msg", user="alice", action="delete") + assert err.context == {"user": "alice", "action": "delete"} + + def test_is_exception(self): + err = AppError("oops") + assert isinstance(err, Exception) + + def test_str_is_message(self): + err = AppError("oops") + assert str(err) == "oops" + + +class TestNotFoundError: + def test_status_code(self): + assert NotFoundError.status_code == 404 + + def test_error_code(self): + assert NotFoundError.error_code == "not_found" + + def test_message_defaults_to_class_name(self): + err = NotFoundError() + assert err.message == "NotFoundError" + + def test_custom_message(self): + err = NotFoundError("project 'foo' not found") + assert err.message == "project 'foo' not found" + + def test_is_app_error(self): + assert issubclass(NotFoundError, AppError) + + +class TestConflictError: + def test_status_code(self): + assert ConflictError.status_code == 409 + + def test_error_code(self): + assert ConflictError.error_code == "conflict" + + def test_is_app_error(self): + assert issubclass(ConflictError, AppError) + + +class TestValidationError: + def test_status_code(self): + assert ValidationError.status_code == 422 + + def test_error_code(self): + assert ValidationError.error_code == "validation_error" + + def test_is_app_error(self): + assert issubclass(ValidationError, AppError) + + +class TestPermissionError: + def test_status_code(self): + assert PermissionError.status_code == 403 + + def test_error_code(self): + assert PermissionError.error_code == "permission_denied" + + def test_context_kwargs(self): + err = PermissionError(required_role="admin") + assert err.context == {"required_role": "admin"} + + def test_is_app_error(self): + assert issubclass(PermissionError, AppError) + + +class TestAuthenticationError: + def test_status_code(self): + assert AuthenticationError.status_code == 401 + + def test_error_code(self): + assert AuthenticationError.error_code == "authentication_error" + + def test_is_app_error(self): + assert issubclass(AuthenticationError, AppError) + + +class TestExceptionRaising: + def test_not_found_can_be_raised_and_caught(self): + with pytest.raises(NotFoundError) as exc_info: + raise NotFoundError("project not found") + assert exc_info.value.message == "project not found" + assert exc_info.value.status_code == 404 + + def test_app_error_catches_subclasses(self): + with pytest.raises(AppError): + raise ConflictError("duplicate") + + def test_context_available_after_raise(self): + with pytest.raises(ValidationError) as exc_info: + raise ValidationError("bad field", field="email", value="oops") + assert exc_info.value.context == {"field": "email", "value": "oops"} + + +# --------------------------------------------------------------------------- +# _sanitize_validation_errors +# --------------------------------------------------------------------------- + + +class TestSanitizeValidationErrors: + def test_drops_input_and_url_keys(self): + errors = [ + {"type": "missing", "loc": ("body", "name"), "msg": "Field required", "input": {"secret": 1}, "url": "https://errors.pydantic.dev/x"} + ] + sanitized = _sanitize_validation_errors(errors) + assert sanitized == [{"type": "missing", "loc": ("body", "name"), "msg": "Field required"}] + + def test_echoed_input_never_survives(self): + errors = [{"msg": "bad", "input": "user-supplied-payload"}] + assert "input" not in _sanitize_validation_errors(errors)[0] + + def test_multiple_errors_all_sanitized(self): + errors = [ + {"msg": "a", "input": 1, "url": "u"}, + {"msg": "b", "input": 2}, + {"msg": "c"}, + ] + sanitized = _sanitize_validation_errors(errors) + assert all("input" not in e and "url" not in e for e in sanitized) + assert [e["msg"] for e in sanitized] == ["a", "b", "c"] + + def test_empty_sequence(self): + assert _sanitize_validation_errors([]) == [] diff --git a/mpcontribs-api/tests/unit/test_packaging.py b/mpcontribs-api/tests/unit/test_packaging.py new file mode 100644 index 000000000..9f5fabdf5 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_packaging.py @@ -0,0 +1,35 @@ +"""Guards against the src-layout packaging regression where the built wheel shipped no code. + +The wheel must contain the importable package so the Docker image's +``uvicorn mpcontribs_api.app:app`` can resolve the module. +""" + +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv not available") +def test_wheel_contains_package(tmp_path: Path) -> None: + result = subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(tmp_path)], + cwd=PROJECT_ROOT, + env={"SETUPTOOLS_SCM_PRETEND_VERSION": "0.0.0", "PATH": __import__("os").environ.get("PATH", "")}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"uv build failed: {result.stderr}" + + wheels = list(tmp_path.glob("*.whl")) + assert wheels, "no wheel produced" + + with zipfile.ZipFile(wheels[0]) as zf: + names = zf.namelist() + assert any(n == "mpcontribs_api/app.py" for n in names), ( + f"wheel is missing the package code; top entries: {sorted(names)[:10]}" + ) diff --git a/mpcontribs-api/tests/unit/test_pagination.py b/mpcontribs-api/tests/unit/test_pagination.py new file mode 100644 index 000000000..f3456e82a --- /dev/null +++ b/mpcontribs-api/tests/unit/test_pagination.py @@ -0,0 +1,102 @@ +import base64 + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.pagination import ( + CursorParams, + Page, + decode_cursor, + encode_cursor, +) + + +class TestEncodeCursor: + def test_roundtrip(self): + original = "some-mongo-id-abc123" + assert decode_cursor(encode_cursor(original)) == original + + def test_returns_string(self): + result = encode_cursor("abc") + assert isinstance(result, str) + + def test_uses_urlsafe_base64(self): + # urlsafe_b64encode uses - and _ instead of + and / + result = encode_cursor("test-id") + decoded_bytes = base64.urlsafe_b64decode(result.encode()) + assert decoded_bytes.decode() == "test-id" + + def test_empty_string(self): + assert decode_cursor(encode_cursor("")) == "" + + def test_encodes_unicode(self): + original = "projet-données" + assert decode_cursor(encode_cursor(original)) == original + + +class TestDecodeCursor: + def test_valid_cursor(self): + encoded = base64.urlsafe_b64encode(b"abc123").decode() + assert decode_cursor(encoded) == "abc123" + + def test_invalid_base64_raises_value_error(self): + with pytest.raises(ValueError, match="malformed cursor"): + decode_cursor("!!!not-valid-base64!!!") + + def test_non_utf8_bytes_raises_value_error(self): + # Encode raw bytes that aren't valid UTF-8 + bad = base64.urlsafe_b64encode(b"\xff\xfe").decode() + with pytest.raises(ValueError, match="malformed cursor"): + decode_cursor(bad) + + +class TestCursorParams: + def test_defaults(self): + params = CursorParams() + assert params.cursor is None + assert params.limit == 20 + + def test_cursor_set(self): + params = CursorParams(cursor="abc123") + assert params.cursor == "abc123" + + def test_limit_minimum(self): + params = CursorParams(limit=1) + assert params.limit == 1 + + def test_limit_maximum(self): + params = CursorParams(limit=100) + assert params.limit == 100 + + def test_limit_below_minimum_raises(self): + with pytest.raises(PydanticValidationError): + CursorParams(limit=0) + + def test_limit_above_maximum_raises(self): + with pytest.raises(PydanticValidationError): + CursorParams(limit=101) + + def test_custom_limit(self): + params = CursorParams(limit=50) + assert params.limit == 50 + + +class TestPage: + def test_items_and_no_next_cursor(self): + page: Page[str] = Page(items=["a", "b", "c"]) + assert page.items == ["a", "b", "c"] + assert page.next_cursor is None + + def test_with_next_cursor(self): + cursor = encode_cursor("last-id") + page: Page[str] = Page(items=["a"], next_cursor=cursor) + assert page.next_cursor == cursor + + def test_empty_items(self): + page: Page[str] = Page(items=[]) + assert page.items == [] + assert page.next_cursor is None + + def test_generic_item_types(self): + page: Page[int] = Page(items=[1, 2, 3]) + assert page.items == [1, 2, 3] diff --git a/mpcontribs-api/tests/unit/test_projection.py b/mpcontribs-api/tests/unit/test_projection.py new file mode 100644 index 000000000..a8c627155 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_projection.py @@ -0,0 +1,328 @@ +from typing import Any + +import pytest +from pydantic import BaseModel, Field + +from mpcontribs_api.exceptions import ValidationError as AppValidationError +from mpcontribs_api.projection import ( + SparseFieldsModel, + _classify, + _collapse, + _unwrap_optional, + _validate_path, + _walk_path, +) + +# --------------------------------------------------------------------------- +# Helpers — simple models used across tests +# --------------------------------------------------------------------------- + + +class Address(BaseModel): + street: str + city: str + country: str | None = None + + +class Simple(SparseFieldsModel): + name: str + age: int + score: float | None = None + tags: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + address: Address | None = None + + +# --------------------------------------------------------------------------- +# _unwrap_optional +# --------------------------------------------------------------------------- + + +class TestUnwrapOptional: + def test_strips_none(self): + result = _unwrap_optional(str | None) + assert result is str + + def test_plain_annotation_unchanged(self): + result = _unwrap_optional(str) + assert result is str + + def test_optional_model(self): + result = _unwrap_optional(Address | None) + assert result is Address + + def test_union_without_none_unchanged(self): + # int | str has no None — returned as-is + result = _unwrap_optional(int | str) + assert result == (int | str) + + +# --------------------------------------------------------------------------- +# _classify +# --------------------------------------------------------------------------- + + +class TestClassify: + def test_base_model_subclass(self): + kind, model = _classify(Address) + assert kind == "model" + assert model is Address + + def test_optional_model(self): + kind, model = _classify(Address | None) + assert kind == "model" + assert model is Address + + def test_dict_annotation(self): + kind, model = _classify(dict) + assert kind == "dict" + assert model is None + + def test_dict_generic(self): + kind, model = _classify(dict[str, Any]) + assert kind == "dict" + assert model is None + + def test_list_annotation(self): + kind, model = _classify(list[str]) + assert kind == "list" + assert model is None + + def test_scalar_str(self): + kind, model = _classify(str) + assert kind == "scalar" + assert model is None + + def test_scalar_int(self): + kind, model = _classify(int) + assert kind == "scalar" + assert model is None + + def test_any(self): + kind, model = _classify(Any) + assert kind == "dict" + assert model is None + + +# --------------------------------------------------------------------------- +# _collapse +# --------------------------------------------------------------------------- + + +class TestCollapse: + def test_no_overlap(self): + paths = frozenset({"name", "age"}) + assert _collapse(paths) == paths + + def test_parent_subsumes_child(self): + paths = frozenset({"address", "address.city"}) + assert _collapse(paths) == frozenset({"address"}) + + def test_child_without_parent_kept(self): + paths = frozenset({"address.city"}) + assert _collapse(paths) == paths + + def test_multiple_children_of_same_parent(self): + paths = frozenset({"address", "address.city", "address.street"}) + assert _collapse(paths) == frozenset({"address"}) + + def test_disjoint_nested_paths(self): + paths = frozenset({"address.city", "name"}) + assert _collapse(paths) == paths + + def test_single_path(self): + paths = frozenset({"name"}) + assert _collapse(paths) == paths + + def test_empty(self): + assert _collapse(frozenset()) == frozenset() + + +# --------------------------------------------------------------------------- +# _walk_path +# --------------------------------------------------------------------------- + + +class TestWalkPath: + def test_scalar_field(self): + steps = list(_walk_path(Simple, "name")) + assert len(steps) == 1 + assert steps[0].segment == "name" + assert steps[0].kind == "scalar" + assert steps[0].is_last is True + + def test_nested_model_field(self): + steps = list(_walk_path(Simple, "address.city")) + assert steps[0].segment == "address" + assert steps[0].kind == "model" + assert steps[0].is_last is False + assert steps[1].segment == "city" + assert steps[1].kind == "scalar" + assert steps[1].is_last is True + + def test_unknown_field(self): + steps = list(_walk_path(Simple, "nonexistent")) + assert steps[0].kind == "unknown" + + def test_dict_field_goes_opaque(self): + steps = list(_walk_path(Simple, "metadata.arbitrary_key")) + assert steps[0].kind == "dict" + assert steps[1].kind == "opaque" + + +# --------------------------------------------------------------------------- +# _validate_path +# --------------------------------------------------------------------------- + + +class TestValidatePath: + def test_valid_scalar_path(self): + _validate_path(Simple, "name") # should not raise + + def test_valid_nested_path(self): + _validate_path(Simple, "address.city") # should not raise + + def test_valid_dict_path(self): + _validate_path(Simple, "metadata.anything") # opaque — allowed + + def test_unknown_top_level_raises(self): + with pytest.raises(AppValidationError, match="unknown field"): + _validate_path(Simple, "nonexistent") + + def test_subfield_of_scalar_raises(self): + with pytest.raises(AppValidationError, match="cannot select subfields"): + _validate_path(Simple, "name.sub") + + def test_subfield_of_list_raises(self): + with pytest.raises(AppValidationError, match="cannot select subfields"): + _validate_path(Simple, "tags.sub") + + +# --------------------------------------------------------------------------- +# SparseFieldsModel.field_names +# --------------------------------------------------------------------------- + + +class TestFieldNames: + def test_returns_top_level_fields(self): + names = Simple.field_names() + assert "name" in names + assert "age" in names + assert "address" in names + assert "metadata" in names + + def test_does_not_include_nested(self): + names = Simple.field_names() + assert "city" not in names + assert "street" not in names + + +# --------------------------------------------------------------------------- +# SparseFieldsModel.parse_fields +# --------------------------------------------------------------------------- + + +class TestParseFields: + def test_none_returns_none(self): + assert Simple.parse_fields(None) is None + + def test_empty_list_returns_none(self): + assert Simple.parse_fields([]) is None + + def test_single_valid_field(self): + result = Simple.parse_fields(["name"]) + assert result is not None + assert "name" in result + + def test_multiple_fields(self): + result = Simple.parse_fields(["name", "age"]) + assert result is not None + assert "name" in result + assert "age" in result + + def test_whitespace_stripped(self): + result = Simple.parse_fields([" name ", " age "]) + assert result is not None + assert "name" in result + assert "age" in result + + def test_nested_field(self): + result = Simple.parse_fields(["address.city"]) + assert result is not None + assert "address.city" in result + + def test_parent_collapses_child(self): + result = Simple.parse_fields(["address", "address.city"]) + assert result is not None + assert "address" in result + assert "address.city" not in result + + def test_unknown_field_raises(self): + with pytest.raises(AppValidationError, match="unknown field"): + Simple.parse_fields(["nonexistent"]) + + def test_scalar_subfield_raises(self): + with pytest.raises(AppValidationError, match="cannot select subfields"): + Simple.parse_fields(["name.sub"]) + + def test_sparse_always_always_included(self): + class WithAlways(SparseFieldsModel): + id: str | None = None + name: str | None = None + sparse_always = frozenset({"id"}) + + result = WithAlways.parse_fields(["name"]) + assert result is not None + assert "id" in result + assert "name" in result + + +# --------------------------------------------------------------------------- +# SparseFieldsModel.projection +# --------------------------------------------------------------------------- + + +class TestProjection: + def test_none_fields_returns_self(self): + assert Simple.projection(None) is Simple + + def test_with_fields_returns_different_model(self): + fields = Simple.parse_fields(["name"]) + result = Simple.projection(fields) + assert result is not Simple + + def test_projected_model_has_settings_with_projection(self): + fields = Simple.parse_fields(["name"]) + projected = Simple.projection(fields) + assert hasattr(projected, "Settings") + assert hasattr(projected.Settings, "projection") + + def test_projection_includes_id(self): + fields = Simple.parse_fields(["name"]) + projected = Simple.projection(fields) + assert "_id" in projected.Settings.projection + + def test_projection_caching(self): + fields = Simple.parse_fields(["name"]) + first = Simple.projection(fields) + second = Simple.projection(fields) + assert first is second + + +# --------------------------------------------------------------------------- +# SparseFieldsModel._identity_fields +# --------------------------------------------------------------------------- + + +class TestIdentityFields: + def test_field_with_id_alias_detected(self): + class WithId(SparseFieldsModel): + id: str | None = Field(default=None, alias="_id", serialization_alias="id") + name: str | None = None + + identity = WithId._identity_fields() + assert "id" in identity + + def test_no_id_field_returns_empty(self): + identity = Simple._identity_fields() + assert identity == frozenset() diff --git a/mpcontribs-api/tests/unit/test_supervisord_render.py b/mpcontribs-api/tests/unit/test_supervisord_render.py new file mode 100644 index 000000000..e7461f21a --- /dev/null +++ b/mpcontribs-api/tests/unit/test_supervisord_render.py @@ -0,0 +1,61 @@ +"""The rendered supervisord config must export the env vars the new pydantic Settings require. + +The FastAPI rewrite reads nested settings via the ``MPCONTRIBS___`` convention. This +guards against regressing the supervisord template back to the Flask-era flat names, which would +make the container fail Settings validation at startup. +""" + +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader + +SUPERVISORD_DIR = Path(__file__).resolve().parents[2] / "supervisord" + +# Names the new Settings model needs present in the process environment. +REQUIRED_ENV_NAMES = [ + "MPCONTRIBS_ENVIRONMENT", + "MPCONTRIBS_MAIL_DEFAULT_SENDER", + "MPCONTRIBS_VERSION", + "MPCONTRIBS_OTEL__OTLP_ENDPOINT", +] + + +def _render() -> str: + env = Environment(loader=FileSystemLoader(str(SUPERVISORD_DIR))) + template = env.get_template("supervisord.conf.jinja") + return template.render( + production=True, + environment="prod", + version="1.2.3", + deployments={ + "ml": { + "api_port": "10002", + "portal_port": 8082, + "db": "ml", + "s3": "ml", + "tm": "MP", + "max_projects": 3, + } + }, + nworkers=2, + reload=0, + node_env="production", + flask_debug=False, + flask_log_level="INFO", + jupyter_gateway_url="http://localhost:10100", + jupyter_gateway_host="localhost:10100", + otel_endpoint="localhost:4317", + mpcontribs_api_host="localhost", + ) + + +@pytest.mark.parametrize("name", REQUIRED_ENV_NAMES) +def test_required_env_name_present(name: str) -> None: + assert name in _render() + + +def test_no_stale_flat_mongo_db_name() -> None: + rendered = _render() + # The old flat name must not appear as an assignment (the nested name is required instead). + assert "MPCONTRIBS_DB_NAME=" not in rendered diff --git a/mpcontribs-api/tests/unit/test_types.py b/mpcontribs-api/tests/unit/test_types.py new file mode 100644 index 000000000..46c43d8bf --- /dev/null +++ b/mpcontribs-api/tests/unit/test_types.py @@ -0,0 +1,101 @@ +import pytest +from pydantic import BaseModel +from pydantic import ValidationError as PydanticValidationError + +from mpcontribs_api.exceptions import ValidationError as AppValidationError +from mpcontribs_api.domains._shared.types import PrefixedEmail, ShortStr, _validate_prefixed_email + + +class ShortStrModel(BaseModel): + value: ShortStr + + +class PrefixedEmailModel(BaseModel): + email: PrefixedEmail + + +class TestShortStr: + def test_valid_3_chars(self): + m = ShortStrModel(value="abc") + assert m.value == "abc" + + def test_valid_30_chars(self): + m = ShortStrModel(value="a" * 30) + assert m.value == "a" * 30 + + def test_valid_mid_length(self): + m = ShortStrModel(value="test-project") + assert m.value == "test-project" + + def test_too_short_raises(self): + with pytest.raises(PydanticValidationError): + ShortStrModel(value="ab") + + def test_empty_raises(self): + with pytest.raises(PydanticValidationError): + ShortStrModel(value="") + + def test_too_long_raises(self): + with pytest.raises(PydanticValidationError): + ShortStrModel(value="a" * 31) + + def test_exactly_31_chars_raises(self): + with pytest.raises(PydanticValidationError): + ShortStrModel(value="a" * 31) + + +class TestValidatePrefixedEmail: + def test_valid_format(self): + assert _validate_prefixed_email("google:alice@example.com") == "google:alice@example.com" + + def test_strips_surrounding_whitespace(self): + assert _validate_prefixed_email(" google:alice@example.com ") == "google:alice@example.com" + + def test_no_colon_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("googlealice@example.com") + + def test_no_at_sign_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("google:aliceexample.com") + + def test_no_domain_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("google:alice@") + + def test_no_tld_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("google:alice@example") + + def test_empty_provider_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email(":alice@example.com") + + def test_empty_name_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("google:@example.com") + + def test_multiple_at_signs_raises(self): + with pytest.raises(AppValidationError): + _validate_prefixed_email("google:alice@@example.com") + + def test_multiple_colons_raises(self): + # The regex requires no colon in provider or local part + with pytest.raises(AppValidationError): + _validate_prefixed_email("goo:gle:alice@example.com") + + +class TestPrefixedEmailModel: + def test_valid_email(self): + m = PrefixedEmailModel(email="github:bob@github.com") + assert m.email == "github:bob@github.com" + + def test_invalid_email_raises_app_validation_error(self): + # BeforeValidator raises AppValidationError; Pydantic does not wrap + # non-standard exceptions (ValueError/TypeError/AssertionError) from validators. + with pytest.raises(AppValidationError): + PrefixedEmailModel(email="not-an-email") + + def test_whitespace_stripped(self): + m = PrefixedEmailModel(email=" orcid:12345@orcid.org ") + assert m.email == "orcid:12345@orcid.org" diff --git a/mpcontribs-api/tests/unit/test_types_components.py b/mpcontribs-api/tests/unit/test_types_components.py new file mode 100644 index 000000000..eb4308989 --- /dev/null +++ b/mpcontribs-api/tests/unit/test_types_components.py @@ -0,0 +1,206 @@ +import polars as pl +import pytest +from pydantic import BaseModel, ConfigDict + +from mpcontribs_api.domains._shared.types import ( + DownloadFormat, + FileLike, + MD5Hash, + MimeFormat, + PolarsFrame, + _coerce_frame, + _serialize_frame, +) +from mpcontribs_api.exceptions import ValidationError as AppValidationError + + +class FileLikeModel(BaseModel): + name: FileLike + + +class MD5Model(BaseModel): + digest: MD5Hash + + +class MimeModel(BaseModel): + mime: MimeFormat + + +class FrameModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + data: PolarsFrame + + +# --------------------------------------------------------------------------- +# FileLike / _file_name_like_str +# --------------------------------------------------------------------------- + + +class TestFileLike: + def test_simple_extension(self): + assert FileLikeModel(name="archive.gz").name == "archive.gz" + + def test_multiple_dots_valid(self): + assert FileLikeModel(name="data.tar.gz").name == "data.tar.gz" + + def test_strips_surrounding_whitespace(self): + assert FileLikeModel(name=" notes.txt ").name == "notes.txt" + + def test_no_extension_raises(self): + with pytest.raises(AppValidationError): + FileLikeModel(name="noextension") + + def test_trailing_dot_raises(self): + with pytest.raises(AppValidationError): + FileLikeModel(name="name.") + + def test_empty_string_raises(self): + with pytest.raises(AppValidationError): + FileLikeModel(name="") + + def test_leading_dot_file_is_accepted(self): + # Documents current behavior: dotfiles split into ["", "ext"], which + # has a non-empty last part and therefore passes. + assert FileLikeModel(name=".gitignore").name == ".gitignore" + + +# --------------------------------------------------------------------------- +# MD5Hash / _md5_like +# --------------------------------------------------------------------------- + + +class TestMD5Hash: + def test_valid_lowercase_hex(self): + assert MD5Model(digest="a" * 32).digest == "a" * 32 + + def test_uppercase_normalized_to_lowercase(self): + assert MD5Model(digest="ABCDEF" + "0" * 26).digest == "abcdef" + "0" * 26 + + def test_strips_whitespace(self): + assert MD5Model(digest=f" {'b' * 32} ").digest == "b" * 32 + + def test_too_short_raises(self): + with pytest.raises(AppValidationError): + MD5Model(digest="a" * 31) + + def test_too_long_raises(self): + with pytest.raises(AppValidationError): + MD5Model(digest="a" * 33) + + def test_non_hex_characters_raise(self): + with pytest.raises(AppValidationError): + MD5Model(digest="g" * 32) + + def test_empty_raises(self): + with pytest.raises(AppValidationError): + MD5Model(digest="") + + +# --------------------------------------------------------------------------- +# MimeFormat / _mime_like +# --------------------------------------------------------------------------- + + +class TestMimeFormat: + def test_valid_application_mime(self): + assert MimeModel(mime="application/gzip").mime == "application/gzip" + + def test_uppercase_normalized(self): + assert MimeModel(mime="APPLICATION/JSON").mime == "application/json" + + def test_strips_whitespace(self): + assert MimeModel(mime=" application/zip ").mime == "application/zip" + + def test_non_application_prefix_raises(self): + with pytest.raises(AppValidationError): + MimeModel(mime="text/plain") + + def test_empty_subtype_raises(self): + with pytest.raises(AppValidationError): + MimeModel(mime="application/") + + def test_missing_slash_raises(self): + with pytest.raises(AppValidationError): + MimeModel(mime="applicationgzip") + + def test_extra_slash_raises(self): + with pytest.raises(AppValidationError): + MimeModel(mime="application/x/y") + + +# --------------------------------------------------------------------------- +# DownloadFormat +# --------------------------------------------------------------------------- + + +class TestDownloadFormat: + def test_members(self): + assert {f.value for f in DownloadFormat} == {"jsonl", "csv"} + + def test_constructible_from_value(self): + assert DownloadFormat("jsonl") is DownloadFormat.JSONL + assert DownloadFormat("csv") is DownloadFormat.CSV + + def test_str_enum_compares_to_plain_str(self): + assert DownloadFormat.CSV == "csv" + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + DownloadFormat("parquet") + + +# --------------------------------------------------------------------------- +# PolarsFrame: _coerce_frame / _serialize_frame +# --------------------------------------------------------------------------- + + +class TestCoerceFrame: + def test_dataframe_passthrough_is_same_object(self): + df = pl.DataFrame({"a": [1, 2]}) + assert _coerce_frame(df) is df + + def test_dict_coerced_to_dataframe(self): + result = _coerce_frame({"a": [1, 2], "b": [3.0, 4.0]}) + assert isinstance(result, pl.DataFrame) + assert result.columns == ["a", "b"] + assert result.height == 2 + + def test_unsupported_type_raises_value_error(self): + with pytest.raises(ValueError, match="cannot coerce"): + _coerce_frame([[1, 2], [3, 4]]) + + def test_none_raises_value_error(self): + with pytest.raises(ValueError, match="cannot coerce"): + _coerce_frame(None) + + +class TestSerializeFrame: + def test_round_trips_to_column_dict(self): + df = pl.DataFrame({"x": [1, 2], "y": [10, 20]}) + assert _serialize_frame(df) == {"x": [1, 2], "y": [10, 20]} + + def test_empty_frame(self): + assert _serialize_frame(pl.DataFrame({"x": []})) == {"x": []} + + +class TestPolarsFrameAnnotated: + def test_model_accepts_dict(self): + m = FrameModel(data={"a": [1, 2]}) + assert isinstance(m.data, pl.DataFrame) + assert m.data["a"].to_list() == [1, 2] + + def test_model_accepts_dataframe(self): + df = pl.DataFrame({"a": [1]}) + assert FrameModel(data=df).data is df + + def test_model_dump_serializes_to_dict(self): + m = FrameModel(data={"a": [1, 2]}) + assert m.model_dump() == {"data": {"a": [1, 2]}} + + def test_model_dump_json_round_trip(self): + m = FrameModel(data={"a": [1, 2]}) + assert m.model_dump_json() == '{"data":{"a":[1,2]}}' + + def test_invalid_payload_fails_validation(self): + with pytest.raises(Exception): + FrameModel(data=42) diff --git a/mpcontribs-api/uv.lock b/mpcontribs-api/uv.lock new file mode 100644 index 000000000..1d0de33fc --- /dev/null +++ b/mpcontribs-api/uv.lock @@ -0,0 +1,2423 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform != 'win32'", +] + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[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 = "basedpyright" +version = "1.39.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/1a/48296b4479ccc9051eb9617a6507a69a68f5b68693fb6a118cfe08199270/basedpyright-1.39.6.tar.gz", hash = "sha256:d00ec5f8ba4e1a67dfc2fa3a9474229c89f61f207d14c02d320db78f57aa16ef", size = 25504244, upload-time = "2026-05-24T07:44:41.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/07/6d1b3192715d42e8c9887876684a941eff28ec5d79c23a0f3758377e2182/basedpyright-1.39.6-py3-none-any.whl", hash = "sha256:5e0b9befbae6b26d0fbcc6645ac26923725e749d1224539e24f05ab07f9365ad", size = 13182122, upload-time = "2026-05-24T07:44:47.086Z" }, +] + +[[package]] +name = "beanie" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lazy-model" }, + { name = "pydantic" }, + { name = "pymongo" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/9c/f3037fff9ea059d7b0c72b6c6e4f3dcfeb28bf544fe0fc5420335d7c49d0/beanie-2.1.0.tar.gz", hash = "sha256:44f3c50710aa90daa9c9c40f9bf9c8954968fe16d7e5398c1f2fd462daf94fe1", size = 185979, upload-time = "2026-03-26T01:27:03.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b5/1c6c404bcce8fa53d3f422dce6e58bff99c3e3cb2a3c49d519e3e5822125/beanie-2.1.0-py3-none-any.whl", hash = "sha256:077381dad0e0129fd4dc38cdaa3d85cb517da7338e3d893a689314884df4379b", size = 92697, upload-time = "2026-03-26T01:27:02.191Z" }, +] + +[[package]] +name = "bibtexparser" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/1c/577d3ce406e88f370e80a6ebf76ae52a2866521e0b585e8ec612759894f1/bibtexparser-1.4.4.tar.gz", hash = "sha256:093b6c824f7a71d3a748867c4057b71f77c55b8dbc07efc993b781771520d8fb", size = 55594, upload-time = "2026-01-29T18:58:01.366Z" } + +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/81/79693e833291c00dc89ee610e5e915381b6f08233912e28df50106840780/botocore_stubs-1.43.14.tar.gz", hash = "sha256:9e3bc1fdd51da7473f0df726c82747a1b0ae913449d629659765c247fecc2039", size = 42738, upload-time = "2026-05-25T06:06:37.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ca/f017727b11895908c5dedc829cf2ec35e0c4b2a26ba875db325fef2cefdf/botocore_stubs-1.43.14-py3-none-any.whl", hash = "sha256:fb98f1475c92fd718644e786b5c543a20f1b1f610e89e0a7191c3f1f429c75aa", size = 67093, upload-time = "2026-05-25T06:06:34.532Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[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.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cramjam" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/12/34bf6e840a79130dfd0da7badfb6f7810b8fcfd60e75b0539372667b41b6/cramjam-2.11.0.tar.gz", hash = "sha256:5c82500ed91605c2d9781380b378397012e25127e89d64f460fea6aeac4389b4", size = 99100, upload-time = "2025-07-27T21:25:07.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/07/a1051cdbbe6d723df16d756b97f09da7c1adb69e29695c58f0392bc12515/cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7ba5e38c9fbd06f086f4a5a64a1a5b7b417cd3f8fc07a20e5c03651f72f36100", size = 3554141, upload-time = "2025-07-27T21:23:17.938Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/58487d2e16ef3d04f51a7c7f0e69823e806744b4c21101e89da4873074bc/cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8adeee57b41fe08e4520698a4b0bd3cc76dbd81f99424b806d70a5256a391d3", size = 1860353, upload-time = "2025-07-27T21:23:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/67/b4/67f6254d166ffbcc9d5fa1b56876eaa920c32ebc8e9d3d525b27296b693b/cramjam-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b96a74fa03a636c8a7d76f700d50e9a8bc17a516d6a72d28711225d641e30968", size = 1693832, upload-time = "2025-07-27T21:23:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/55/a3/4e0b31c0d454ae70c04684ed7c13d3c67b4c31790c278c1e788cb804fa4a/cramjam-2.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c3811a56fa32e00b377ef79121c0193311fd7501f0fb378f254c7f083cc1fbe0", size = 2027080, upload-time = "2025-07-27T21:23:23.303Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c7/5e8eed361d1d3b8be14f38a54852c5370cc0ceb2c2d543b8ba590c34f080/cramjam-2.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5d927e87461f8a0d448e4ab5eb2bca9f31ca5d8ea86d70c6f470bb5bc666d7e", size = 1761543, upload-time = "2025-07-27T21:23:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/09/0c/06b7f8b0ce9fde89470505116a01fc0b6cb92d406c4fb1e46f168b5d3fa5/cramjam-2.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f1f5c450121430fd89cb5767e0a9728ecc65997768fd4027d069cb0368af62f9", size = 1854636, upload-time = "2025-07-27T21:23:26.987Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c6/6ebc02c9d5acdf4e5f2b1ec6e1252bd5feee25762246798ae823b3347457/cramjam-2.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:724aa7490be50235d97f07e2ca10067927c5d7f336b786ddbc868470e822aa25", size = 2032715, upload-time = "2025-07-27T21:23:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/a122971c23f5ca4b53e4322c647ac7554626c95978f92d19419315dddd05/cramjam-2.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54c4637122e7cfd7aac5c1d3d4c02364f446d6923ea34cf9d0e8816d6e7a4936", size = 2069039, upload-time = "2025-07-27T21:23:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f6121b90b86b9093c066889274d26a1de3f29969d45c2ed1ecbe2033cb78/cramjam-2.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17eb39b1696179fb471eea2de958fa21f40a2cd8bf6b40d428312d5541e19dc4", size = 1979566, upload-time = "2025-07-27T21:23:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/f95bc57fd7f4166ce6da816cfa917fb7df4bb80e669eb459d85586498414/cramjam-2.11.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:36aa5a798aa34e11813a80425a30d8e052d8de4a28f27bfc0368cfc454d1b403", size = 2030905, upload-time = "2025-07-27T21:23:33.696Z" }, + { url = "https://files.pythonhosted.org/packages/fc/52/e429de4e8bc86ee65e090dae0f87f45abd271742c63fb2d03c522ffde28a/cramjam-2.11.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:449fca52774dc0199545fbf11f5128933e5a6833946707885cf7be8018017839", size = 2155592, upload-time = "2025-07-27T21:23:35.375Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6c/65a7a0207787ad39ad804af4da7f06a60149de19481d73d270b540657234/cramjam-2.11.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:d87d37b3d476f4f7623c56a232045d25bd9b988314702ea01bd9b4a94948a778", size = 2170839, upload-time = "2025-07-27T21:23:37.197Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c5/5c5db505ba692bc844246b066e23901d5905a32baf2f33719c620e65887f/cramjam-2.11.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:26cb45c47d71982d76282e303931c6dd4baee1753e5d48f9a89b3a63e690b3a3", size = 2157236, upload-time = "2025-07-27T21:23:38.854Z" }, + { url = "https://files.pythonhosted.org/packages/b0/22/88e6693e60afe98901e5bbe91b8dea193e3aa7f42e2770f9c3339f5c1065/cramjam-2.11.0-cp314-cp314-win32.whl", hash = "sha256:4efe919d443c2fd112fe25fe636a52f9628250c9a50d9bddb0488d8a6c09acc6", size = 1604136, upload-time = "2025-07-27T21:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f8/01618801cd59ccedcc99f0f96d20be67d8cfc3497da9ccaaad6b481781dd/cramjam-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccec3524ea41b9abd5600e3e27001fd774199dbb4f7b9cb248fcee37d4bda84c", size = 1710272, upload-time = "2025-07-27T21:23:42.236Z" }, + { url = "https://files.pythonhosted.org/packages/40/81/6cdb3ed222d13ae86bda77aafe8d50566e81a1169d49ed195b6263610704/cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:966ac9358b23d21ecd895c418c048e806fd254e46d09b1ff0cdad2eba195ea3e", size = 3559671, upload-time = "2025-07-27T21:23:44.504Z" }, + { url = "https://files.pythonhosted.org/packages/cb/43/52b7e54fe5ba1ef0270d9fdc43dabd7971f70ea2d7179be918c997820247/cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:387f09d647a0d38dcb4539f8a14281f8eb6bb1d3e023471eb18a5974b2121c86", size = 1867876, upload-time = "2025-07-27T21:23:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/28/30d5b8d10acd30db3193bc562a313bff722888eaa45cfe32aa09389f2b24/cramjam-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:665b0d8fbbb1a7f300265b43926457ec78385200133e41fef19d85790fc1e800", size = 1695562, upload-time = "2025-07-27T21:23:48.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/86/ec806f986e01b896a650655024ea52a13e25c3ac8a3a382f493089483cdc/cramjam-2.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ca905387c7a371531b9622d93471be4d745ef715f2890c3702479cd4fc85aa51", size = 2025056, upload-time = "2025-07-27T21:23:50.404Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/c2c17586b90848d29d63181f7d14b8bd3a7d00975ad46e3edf2af8af7e1f/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c1aa56aef2c8af55a21ed39040a94a12b53fb23beea290f94d19a76027e2ffb", size = 1764084, upload-time = "2025-07-27T21:23:52.265Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/68bc334fadb434a61df10071dc8606702aa4f5b6cdb2df62474fc21d2845/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5db59c1cdfaa2ab85cc988e602d6919495f735ca8a5fd7603608eb1e23c26d5", size = 1854859, upload-time = "2025-07-27T21:23:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4e/b48e67835b5811ec5e9cb2e2bcba9c3fd76dab3e732569fe801b542c6ca9/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1f893014f00fe5e89a660a032e813bf9f6d91de74cd1490cdb13b2b59d0c9a3", size = 2035970, upload-time = "2025-07-27T21:23:55.758Z" }, + { url = "https://files.pythonhosted.org/packages/c4/70/d2ac33d572b4d90f7f0f2c8a1d60fb48f06b128fdc2c05f9b49891bb0279/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c26a1eb487947010f5de24943bd7c422dad955b2b0f8650762539778c380ca89", size = 2069320, upload-time = "2025-07-27T21:23:57.494Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4c/85cec77af4a74308ba5fca8e296c4e2f80ec465c537afc7ab1e0ca2f9a00/cramjam-2.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5c8bfb438d94e7b892d1426da5fc4b4a5370cc360df9b8d9d77c33b896c37e", size = 1982668, upload-time = "2025-07-27T21:23:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/55/45/938546d1629e008cc3138df7c424ef892719b1796ff408a2ab8550032e5e/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:cb1fb8c9337ab0da25a01c05d69a0463209c347f16512ac43be5986f3d1ebaf4", size = 2034028, upload-time = "2025-07-27T21:24:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/b5a53e20505555f1640e66dcf70394bcf51a1a3a072aa18ea35135a0f9ed/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:1f6449f6de52dde3e2f1038284910c8765a397a25e2d05083870f3f5e7fc682c", size = 2155513, upload-time = "2025-07-27T21:24:02.92Z" }, + { url = "https://files.pythonhosted.org/packages/84/12/8d3f6ceefae81bbe45a347fdfa2219d9f3ac75ebc304f92cd5fcb4fbddc5/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:382dec4f996be48ed9c6958d4e30c2b89435d7c2c4dbf32480b3b8886293dd65", size = 2170035, upload-time = "2025-07-27T21:24:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/3be6f0a1398f976070672be64f61895f8839857618a2d8cc0d3ab529d3dc/cramjam-2.11.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:d388bd5723732c3afe1dd1d181e4213cc4e1be210b080572e7d5749f6e955656", size = 2160229, upload-time = "2025-07-27T21:24:06.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/66cfc3635511b20014bbb3f2ecf0095efb3049e9e96a4a9e478e4f3d7b78/cramjam-2.11.0-cp314-cp314t-win32.whl", hash = "sha256:0a70ff17f8e1d13f322df616505550f0f4c39eda62290acb56f069d4857037c8", size = 1610267, upload-time = "2025-07-27T21:24:08.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c6/c71e82e041c95ffe6a92ac707785500aa2a515a4339c2c7dd67e3c449249/cramjam-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:028400d699442d40dbda02f74158c73d05cb76587a12490d0bfedd958fd49188", size = 1713108, upload-time = "2025-07-27T21:24:10.147Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[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 = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/7c/f194925af8fabdb0b7a886a1b89087c0b7f327f99e79497a882aa94c1e34/fastapi_cloud_cli-0.19.0.tar.gz", hash = "sha256:f97b31c2ad6af3832eb4065870bdca3365b6e827a0ccf6eeb15e477bc1662b13", size = 57476, upload-time = "2026-06-01T08:24:03.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/e6/1a2ec890fc273b9da2b173ca45f692a2e24a369bdd39ea7812c1d8a799e5/fastapi_cloud_cli-0.19.0-py3-none-any.whl", hash = "sha256:a2dfc4074c321e63ec88589cc1f90573d4b5bf980ddc44a7033e6f3cd8e96628", size = 38239, upload-time = "2026-06-01T08:24:02.437Z" }, +] + +[[package]] +name = "fastapi-filter" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ed/c36cfcd849519fd2d23051ad81a91fc5e8cfa7109496fc8a10ad565a5fe9/fastapi_filter-2.0.1.tar.gz", hash = "sha256:cffda370097af7e404f1eb188aca58b199084bfaf7cec881e40b404adf12566e", size = 9857, upload-time = "2024-12-07T17:30:06.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/88/afc022ad64d12f730141fc50758ecf9d60de5fed11335dc16e3127617f05/fastapi_filter-2.0.1-py3-none-any.whl", hash = "sha256:711d48707ec62f7c9e12a7713fc0f6a99858a9e3741b4d108102d5599e77197d", size = 11586, upload-time = "2024-12-07T17:30:05.375Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[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 = "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 = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + +[[package]] +name = "lazy-model" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/85/e25dc36dee49cf0726c03a1558b5c311a17095bc9361bcbf47226cb3075a/lazy-model-0.4.0.tar.gz", hash = "sha256:a851d85d0b518b0b9c8e626bbee0feb0494c0e0cb5636550637f032dbbf9c55f", size = 8256, upload-time = "2025-08-07T20:05:34.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/54/653ea0d7c578741e9867ccf0cbf47b7eac09ff22e4238f311ac20671a911/lazy_model-0.4.0-py3-none-any.whl", hash = "sha256:95ea59551c1ac557a2c299f75803c56cc973923ef78c67ea4839a238142f7927", size = 13749, upload-time = "2025-08-07T20:05:36.303Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[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 = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, +] + +[[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 = "monty" +version = "2026.5.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/d7/bd34aafa34d3cddd93c440e7cfb3371328079e864279708f64a871806eb7/monty-2026.5.18.tar.gz", hash = "sha256:b2d8d5c78030e889f6407fa9d8c8c4e3df05fc380251cdf85a119cd1e49c3fdd", size = 211830, upload-time = "2026-05-18T03:21:02.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/63/86120977f920798494c522c26899ffacde6d848e1eb018403f1d08044d0d/monty-2026.5.18-py3-none-any.whl", hash = "sha256:dd108b5c3cceb1f61de1a854db8ffda3dc7c2a2d4025b24cf397ade1121e7904", size = 59998, upload-time = "2026-05-18T03:21:00.059Z" }, +] + +[[package]] +name = "mpcontribs-api" +source = { editable = "." } +dependencies = [ + { name = "aioboto3" }, + { name = "beanie" }, + { name = "fastapi", extra = ["standard"] }, + { name = "fastapi-filter" }, + { name = "jinja2" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-pymongo" }, + { name = "opentelemetry-sdk" }, + { name = "polars" }, + { name = "pydantic-settings" }, + { name = "pymatgen" }, + { name = "pymongo" }, + { name = "python-snappy" }, + { name = "structlog" }, + { name = "supervisor" }, + { name = "types-aiobotocore", extra = ["s3"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "httpx2" }, + { name = "pydocstringformatter" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aioboto3", specifier = ">=15.5.0" }, + { name = "beanie", specifier = ">=2.1.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.136.3" }, + { name = "fastapi-filter", specifier = ">=2.0.1" }, + { name = "jinja2" }, + { name = "opentelemetry-api", specifier = ">=1.42.1" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.42.1" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.63b1" }, + { name = "opentelemetry-instrumentation-pymongo", specifier = ">=0.63b1" }, + { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, + { name = "polars", specifier = ">=1.41.2" }, + { name = "pydantic-settings", specifier = ">=2.14.1" }, + { name = "pymatgen" }, + { name = "pymongo", specifier = ">=4.17.0" }, + { name = "python-snappy", specifier = ">=0.7.3" }, + { name = "structlog", specifier = ">=25.5.0" }, + { name = "supervisor", specifier = ">=4.3.0" }, + { name = "types-aiobotocore", extras = ["s3"], specifier = ">=3.7.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.29.0" }, + { name = "httpx2", specifier = ">=0.28.0" }, + { name = "pydocstringformatter", specifier = ">=0.7.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = ">=0.9.0" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176, upload-time = "2026-05-16T08:49:08.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201, upload-time = "2026-05-16T08:49:05.536Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" }, + { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/b5/7ea3a9fd1b80e89786c14250bfaecf32a753c3fd08232690f4da8dc16e29/opentelemetry_instrumentation_asgi-0.63b1.tar.gz", hash = "sha256:267b422416d768f3c7f4054883b41d9c3a7c943d86d20032b738c99a3dbb5862", size = 26151, upload-time = "2026-05-21T16:36:18.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/7e/83986f27b421de04fab1e1a84e892621dac42e6432a9c66779505f4d1381/opentelemetry_instrumentation_asgi-0.63b1-py3-none-any.whl", hash = "sha256:1a22453dfa965f14799b10a674b8acbcb897a8a75c79136060af54214cc7886e", size = 15906, upload-time = "2026-05-21T16:35:04.162Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/d6/0c128fac2e34b7d526a8d3c6edc45b875a97f8a987861b00511151b6337d/opentelemetry_instrumentation_fastapi-0.63b1.tar.gz", hash = "sha256:cc42dff56c96d0a2921510c4abab2a4c2e27fe64b26dc1254727fb550df100ba", size = 25387, upload-time = "2026-05-21T16:36:32.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/3d/2eae63f13f36d7a8ab5bf03d06ecaf169c2069b524547f24947be6d92094/opentelemetry_instrumentation_fastapi-0.63b1-py3-none-any.whl", hash = "sha256:52ee2cde9a2ac094bdd45d79f85860e03a972928a2553006071fe61d94cf7281", size = 12795, upload-time = "2026-05-21T16:35:28.68Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-pymongo" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/b2/94c180359165abe62e829250bc6ac6b7daa2334b3c505bad64f1c64f18ab/opentelemetry_instrumentation_pymongo-0.63b1.tar.gz", hash = "sha256:8c0ae185b59dcb45c80bf90d4ffda5fcc6337dbba11de40306ffd69459e476fa", size = 10208, upload-time = "2026-05-21T16:36:41.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/b9/47b8d0f52d81d7661debebb11f4fdd1ab869d694089711c1ac8988456364/opentelemetry_instrumentation_pymongo-0.63b1-py3-none-any.whl", hash = "sha256:0d8dd55b2522eda4a7093da8b5f47fae9a3235fb2786bc14c161d5999a66320d", size = 10290, upload-time = "2026-05-21T16:35:44.634Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d8/7bf5e4cec0578ac3c28c18eb7b88f34279139cbc8c568d6aa02b9c5ae53e/opentelemetry_util_http-0.63b1.tar.gz", hash = "sha256:ba1268f00922ee522dba2ae38458060f99486e7385a8056985901ca9685adfff", size = 11102, upload-time = "2026-05-21T16:36:56.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/f1/34e047e8f6a3c67e5220acf1af7b9f62868c25d77791bca74457bd2180a6/opentelemetry_util_http-0.63b1-py3-none-any.whl", hash = "sha256:6284194028c59cd439f8acfe388145069a6127f11dc077e1344a2094adacc3f8", size = 8205, upload-time = "2026-05-21T16:36:09.736Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[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 = "palettable" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/3d/a5854d60850485bff12f28abfe0e17f503e866763bed61aed4990b604530/palettable-3.3.3.tar.gz", hash = "sha256:094dd7d9a5fc1cca4854773e5c1fc6a315b33bd5b3a8f47064928facaf0490a8", size = 106639, upload-time = "2023-04-19T23:13:35.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f7/3367feadd4ab56783b0971c9b7edfbdd68e0c70ce877949a5dd2117ed4a0/palettable-3.3.3-py2.py3-none-any.whl", hash = "sha256:74e9e7d7fe5a9be065e02397558ed1777b2df0b793a6f4ce1a5ee74f74fb0caa", size = 332251, upload-time = "2023-04-19T23:13:33.996Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "plotly" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + +[[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 = "polars" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[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-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[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 = "pydocstringformatter" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/37/f6d5ff68893c8b4ae194d6dd9df31be2cceacfae5256c840b9e216fd20de/pydocstringformatter-0.7.5.tar.gz", hash = "sha256:e9cbd134d6279360fd2bcaad94680cec02aa20a22560375c5ffd495fcfbcf92d", size = 30474, upload-time = "2025-07-12T10:12:46.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/ed/e70e413b537b7809badcd275a5f050301dafbe54efd1ae9d392ed2943c40/pydocstringformatter-0.7.5-py3-none-any.whl", hash = "sha256:7daed355f11244f64571d119e49e7328365ea9b545f88256a47b550f213d23eb", size = 31433, upload-time = "2025-07-12T10:12:44.619Z" }, +] + +[[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 = "pymatgen" +version = "2026.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymatgen-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/a1/727feae7083bb3b10ba9d43ccbbe83317d4654957a08cee52096ef86bfde/pymatgen-2026.5.4.tar.gz", hash = "sha256:4998d6084da72224c8025dc1e9645b2aab73896109a7ef1e05bd479a25a55b79", size = 743199, upload-time = "2026-05-04T22:03:24.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/3a/de29b0b6c1740098c3ccdd3fa69336dc39b5efc6840a55e30dcaea01a19b/pymatgen-2026.5.4-py3-none-any.whl", hash = "sha256:7815cd4310c6d5a465b0da14a1633479cf61366676e62764db17f7e2a20d1974", size = 829056, upload-time = "2026-05-04T22:03:22.327Z" }, +] + +[[package]] +name = "pymatgen-core" +version = "2026.5.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bibtexparser" }, + { name = "joblib" }, + { name = "lxml" }, + { name = "matplotlib" }, + { name = "monty" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "palettable" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "requests" }, + { name = "scipy" }, + { name = "spglib" }, + { name = "sympy" }, + { name = "tabulate" }, + { name = "tqdm" }, + { name = "uncertainties" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/ed/b52bb7ab2b9ca0e69ea54b919ddba13c5636b960f7b9f4fab0d0aa0a502b/pymatgen_core-2026.5.18.tar.gz", hash = "sha256:bdbe8c591bbac7ed65922d710e94b661bb540baae27217ceadd59e31e9c3ff07", size = 2860012, upload-time = "2026-05-18T23:39:30.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/cf/c075084fc03fbb11ccc9a5d07d37163eb2dfaf6b6981cbec7c1f7955bd6b/pymatgen_core-2026.5.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:41f390215a28ba9c5fba5ccf414a0dca6824a4d463d09008eacc59dfe89c5f45", size = 2927369, upload-time = "2026-05-19T02:15:32.055Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/45cb3d86a9b9ff20fee497e5fe3a10e922785fa269d597a76c19066d4207/pymatgen_core-2026.5.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31833fa81a264842e782039f4cf46a9ceaacb485639ea3d4606f3d06e1d1d6d6", size = 4413086, upload-time = "2026-05-19T02:15:34.007Z" }, + { url = "https://files.pythonhosted.org/packages/19/b8/548cf1d1b78e03535fa2b2fe90860946ed45c1b29befd3d1fe43fc87a005/pymatgen_core-2026.5.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7199b2f7ae7e0ba5ae3af23f5d16e13ae69a37d4015d94d4f62d5ada544fb4eb", size = 4448826, upload-time = "2026-05-19T02:15:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/39/09/2d8e42942421d35e2f0c4db92d7fd9eb52f2f03d688999592a9f758afd4f/pymatgen_core-2026.5.18-cp314-cp314-win32.whl", hash = "sha256:845a4337c84bfbd1e48e9c3200ae6b1868d1486a0e8e277e16d3dc64c46e4f17", size = 2843984, upload-time = "2026-05-19T02:15:37.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6a/8233be4b903bd3ff6e04556b025e1b476cc1918675c2d6b4383db868f9ce/pymatgen_core-2026.5.18-cp314-cp314-win_amd64.whl", hash = "sha256:f4d1a2f2f37b25ff3ce33dd6ff06303b2d1d735c8a525ba2a970e25803afc4b0", size = 2887831, upload-time = "2026-05-19T02:15:38.902Z" }, +] + +[[package]] +name = "pymongo" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[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 = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-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 = "python-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, +] + +[[package]] +name = "python-snappy" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cramjam" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/66/9185fbb6605ba92716d9f77fbb13c97eb671cd13c3ad56bd154016fbf08b/python_snappy-0.7.3.tar.gz", hash = "sha256:40216c1badfb2d38ac781ecb162a1d0ec40f8ee9747e610bcfefdfa79486cee3", size = 9337, upload-time = "2024-08-29T13:16:05.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c1/0ee413ddd639aebf22c85d6db39f136ccc10e6a4b4dd275a92b5c839de8d/python_snappy-0.7.3-py3-none-any.whl", hash = "sha256:074c0636cfcd97e7251330f428064050ac81a52c62ed884fc2ddebbb60ed7f50", size = 9155, upload-time = "2024-08-29T13:16:04.773Z" }, +] + +[[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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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 = "rich-toolkit" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/49/d7a4fd4f39c195b73f78694af3e812943a4181a8d48a11035425d0f6d71f/rich_toolkit-0.20.0.tar.gz", hash = "sha256:bb05382554d4f46865dfca2fccccf30768ef37e0347207d00f034d9b36b25021", size = 203144, upload-time = "2026-06-02T21:11:38.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b5/6b6efd9e305653fae68ed0b712bc659cd3c5541ec54416e6bb14af52acca/rich_toolkit-0.20.0-py3-none-any.whl", hash = "sha256:906e5b8741fafc46159c5f719fd30fd3c9dd8f2c31b8161dc8c612f98b8da01a", size = 35379, upload-time = "2026-06-02T21:11:37.564Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.61.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/3b/4bc6b348bbd331daa14d4babe9f2b99bc854f4da41560eefb9488d78481d/sentry_sdk-2.61.1.tar.gz", hash = "sha256:9c6adccb3feefa9ba032c8d295ca477575c2f11896046a2b0ad686c47c4af555", size = 459429, upload-time = "2026-06-01T07:24:18.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/54/c9218db183846e08efaf68534889ef42e499dde432778881104a42f7071b/sentry_sdk-2.61.1-py3-none-any.whl", hash = "sha256:fa36eaf4b8ad708f718500d4bdcc1532637526a22beb874d88cbc0a46458b5ae", size = 483735, upload-time = "2026-06-01T07:24:17.027Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[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 = "spglib" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/06/7964acb4c444191376bd87f91579475fbe7623ca943cce40cee8fb7f2c36/spglib-2.7.0.tar.gz", hash = "sha256:c40907a42c9dc45572f46740bf95412f84fb0eda30267e31665d104a4bde6627", size = 2366134, upload-time = "2025-12-29T09:48:26.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/47/86e3c15c3e1c252bde40a794eea4742c142f23fc5f9c3d7551f083c1fa20/spglib-2.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95e3dd7ef992ff8a88f6ef2e5909aaa60ecb479004cc1f73c1e6285d54227960", size = 911712, upload-time = "2025-12-29T09:48:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/61/ab2447bb47fa69934adc2fc2d13f771dedd3b2fd3171c95307446c948f01/spglib-2.7.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97e0fcea2db3915bd973fdd2cc0a757b1f99bda71ce815da333d75ad1ffc3eb1", size = 947528, upload-time = "2025-12-29T09:48:03.258Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/898d9e005131b0b1c7e5dce2b79f36aeb20ec4d3a88cca596b522a0fa4df/spglib-2.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39b978c08ef2ebc0eaba833c488fc4c0f9b1fc0f50d4a8584f176741eea69376", size = 962474, upload-time = "2025-12-29T09:48:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/7b25ee5348722dc93ca245ed950f1a89f8a944906140629055f394c072a4/spglib-2.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:5f334b4b66c8aafd583fafab5b15a56e27efdd2dc6cb1064dfcd0fe59ae130f4", size = 679679, upload-time = "2025-12-29T09:48:15.393Z" }, + { url = "https://files.pythonhosted.org/packages/20/37/eda9a34f25b13e47298fa1b94cc4dfd8b0fcfc46c7d63ea046aa1bf91fe7/spglib-2.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:b032842fc223de46d2ef7d220459e1a61ed90329ac2e72818c605f1fc87451b8", size = 656403, upload-time = "2025-12-29T09:48:17.027Z" }, + { url = "https://files.pythonhosted.org/packages/39/af/1c8d0f98d07969b7fa7323d522732124d88caf4ee3b680ef59120bd7b229/spglib-2.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b7e29c796cfdadcc3857aef330acc19b9bc50c83e9911fb23b28390e7c80bae5", size = 920791, upload-time = "2025-12-29T09:48:07.085Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/89a3f31f831efc4108a19f110873559990b72186745cd3e151de28b256cc/spglib-2.7.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b6ca88bb6e604bc8f63efe87b3b2470c2e25f56988b775bd332cefa8866f5c5", size = 946881, upload-time = "2025-12-29T09:48:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/1ca63db2cebd381bd6b27ae309f25d270e70928359a6f0360db09b77894e/spglib-2.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50629939a9cd6fa3df5a12f6f025ceb3c78534284f875371574c360e4ccaf5e1", size = 963803, upload-time = "2025-12-29T09:48:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/28/97/459b37c3802633f77c883883c75f5d4429b601ae8d930410b999c4e1dafb/spglib-2.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cb77daaf9dd5d48d523a888f37cebd47fa63ff28dfcf1aac2b031b914f9ed55a", size = 696536, upload-time = "2025-12-29T09:48:13.885Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "supervisor" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/b5/37e7a3706de436a8a2d75334711dad1afb4ddffab09f25e31d89e467542f/supervisor-4.3.0.tar.gz", hash = "sha256:4a2bf149adf42997e1bb44b70c43b613275ec9852c3edacca86a9166b27e945e", size = 468912, upload-time = "2025-08-23T18:25:02.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/65/5e726c372da8a5e35022a94388b12252710aad0c2351699c3d76ae8dba78/supervisor-4.3.0-py2.py3-none-any.whl", hash = "sha256:0bcb763fddafba410f35cbde226aa7f8514b9fb82eb05a0c85f6588d1c13f8db", size = 320736, upload-time = "2025-08-23T18:25:00.767Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + +[[package]] +name = "types-aiobotocore" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/e8/ef1fcb876937dbdddc0f01b5df4ed53f33b166a6367d80a9014d5e5f091d/types_aiobotocore-3.7.0.tar.gz", hash = "sha256:fe35de52c12e5fdb89ca60b3989766e7fe827e3d2e95fcf4583e91581945205c", size = 87992, upload-time = "2026-05-10T03:19:32.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/68/0cdfd7df415ee3e769c8e8f9bd8013c64c88cdd7306f72453a02123c58f9/types_aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:ff4139b3eae22d242b6b39ba56048344b2b86f67daeeca4680da1a6e191681fd", size = 54804, upload-time = "2026-05-10T03:19:29.487Z" }, +] + +[package.optional-dependencies] +s3 = [ + { name = "types-aiobotocore-s3" }, +] + +[[package]] +name = "types-aiobotocore-s3" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/ce/5b7baa06cde79ab5f083f56f060ccd2b60f6249127d70bbb8a37aafbdcd4/types_aiobotocore_s3-3.7.0.tar.gz", hash = "sha256:6ec738853dbba9133707991b98ea4dab19f7c62e02b3cca016e6cd8e3d684576", size = 77662, upload-time = "2026-05-10T03:17:35.088Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/ac/67240c3804a3e1c1e8f0f402155d7ed5351faf7a2085136083e5ca3f26b8/types_aiobotocore_s3-3.7.0-py3-none-any.whl", hash = "sha256:5dbb5479ece3e1aceaccac224fc3dd1ab7d912c4457419bd19e88a0a5b3844fc", size = 85450, upload-time = "2026-05-10T03:17:33.061Z" }, +] + +[[package]] +name = "types-awscrt" +version = "0.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/59/44409a8fc06b444ab1a6f71dcb29d49a6e17e02424345eb51b051bebb345/types_awscrt-0.34.1.tar.gz", hash = "sha256:559aa04250f6a419a617dfb788f3e10903aaf74700ef23e521b64a411b83b803", size = 19062, upload-time = "2026-06-05T04:40:10.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/b1/214b12162b452ed6acd230065e6c587cde6b96871e3ce6d653f40888f8df/types_awscrt-0.34.1-py3-none-any.whl", hash = "sha256:20c752b6031544d8f694803c35174aee129f1be5ddf886ae46d22f7ffd9b7d75", size = 45688, upload-time = "2026-06-05T04:40:09.198Z" }, +] + +[[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 = "uncertainties" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/0c/cb09f33b26955399c675ab378e4063ed7e48422d3d49f96219ab0be5eba9/uncertainties-3.2.3.tar.gz", hash = "sha256:76a5653e686f617a42922d546a239e9efce72e6b35411b7750a1d12dcba03031", size = 160492, upload-time = "2025-04-21T19:58:28.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl", hash = "sha256:313353900d8f88b283c9bad81e7d2b2d3d4bcc330cbace35403faaed7e78890a", size = 60118, upload-time = "2025-04-21T19:58:26.864Z" }, +] + +[[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 = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +]