diff --git a/.github/workflows/example-data-seeder.yml b/.github/workflows/example-data-seeder.yml index 1b06a9f8c..4ea69aa21 100644 --- a/.github/workflows/example-data-seeder.yml +++ b/.github/workflows/example-data-seeder.yml @@ -37,7 +37,7 @@ jobs: - name: Build and push image uses: docker/build-push-action@v7 with: - context: ./example-data + context: ./collection-seeding tags: ${{ steps.dockerMetadata.outputs.tags }} cache-from: type=gha,scope=example-data-seeder-${{ github.ref }} cache-to: type=gha,mode=max,scope=example-data-seeder-${{ github.ref }} diff --git a/.gitignore b/.gitignore index 35cd109ad..7ac7e8013 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ logs node_modules/ .env + +# Python +__pycache__/ +*.pyc + +# pixi +.pixi/ diff --git a/collection-seeding/Dockerfile b/collection-seeding/Dockerfile new file mode 100644 index 000000000..72c322b8c --- /dev/null +++ b/collection-seeding/Dockerfile @@ -0,0 +1,14 @@ +# Stage 1: use pixi to resolve and install dependencies +FROM ghcr.io/prefix-dev/pixi:0.58.0 AS builder +WORKDIR /app +COPY pixi.toml pixi.lock . +RUN pixi install --frozen + +# Stage 2: slim runtime image — copy only the installed site-packages +FROM python:3.13-slim AS final +WORKDIR /app +COPY --from=builder /app/.pixi/envs/default/lib/python3.13/site-packages \ + /usr/local/lib/python3.13/site-packages +COPY seed.py backend.py . +COPY sources/ sources/ +CMD ["python", "seed.py"] diff --git a/collection-seeding/README.md b/collection-seeding/README.md new file mode 100644 index 000000000..288a42604 --- /dev/null +++ b/collection-seeding/README.md @@ -0,0 +1,43 @@ +# collection-seeding + +Seeds the backend with example collections: + +- **covid-resistance-mutations** — resistance mutation data for 3CLpro, RdRp, and Spike mAb +- **covid-pango-lineages** — one collection per pango lineage, with nucleotide substitutions as variants + +The script is idempotent — re-running it will create new collections or update existing ones (matched by name). + +Collections are seeded under the [genspectrum-bot](https://github.com/genspectrum-bot) account (GitHub ID `218605180`), which is upserted automatically via `POST /users/sync` before seeding. + +## Via Docker Compose + +The seeder runs automatically as part of Docker Compose: + +```bash +BACKEND_TAG=latest WEBSITE_TAG=latest SEEDER_TAG=latest docker compose up +``` + +## Running locally + +Requires [pixi](https://pixi.sh). Install dependencies once: + +```bash +pixi install +``` + +Then use the provided tasks: + +```bash +pixi run seed # all sources (resistance mutations + first 10 lineages) +pixi run seed-resistance # resistance mutations only +pixi run seed-lineages # pango lineages (first 10) +pixi run seed-all-lineages # all ~4976 pango lineages +``` + +To target a different backend: + +```bash +pixi run seed --url http://localhost:9021 +``` + +Run `pixi run seed --help` or `pixi run seed --help` for all options. diff --git a/collection-seeding/backend.py b/collection-seeding/backend.py new file mode 100644 index 000000000..71440cedd --- /dev/null +++ b/collection-seeding/backend.py @@ -0,0 +1,67 @@ +"""Shared backend API client for collection seeders.""" + +import sys +import time + +import requests + +from models import Collection, ExistingCollection + +RETRY_ATTEMPTS = 30 +RETRY_DELAY_S = 2 + + +SYNC_GITHUB_ID = "218605180" # https://github.com/genspectrum-bot +SYNC_NAME = "GenSpectrum Team" + + +class BackendClient: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.user_id: int | None = None + self._collections_url = f"{self.base_url}/collections" + + def sync_user(self, github_id: str = SYNC_GITHUB_ID, name: str = SYNC_NAME, email: str | None = None) -> int: + """Upsert the seed user and store the returned internal id.""" + body = {"githubId": github_id, "name": name, "email": email} + r = requests.post(f"{self.base_url}/users/sync", json=body, timeout=10) + if not r.ok: + raise RuntimeError(f"POST /users/sync failed: {r.status_code} {r.text}") + self.user_id = r.json()["id"] + return self.user_id + + def wait_for_backend(self, attempts: int = RETRY_ATTEMPTS, delay: float = RETRY_DELAY_S): + """Poll until the backend is ready by repeatedly attempting user sync.""" + for attempt in range(1, attempts + 1): + try: + self.sync_user() + return + except (requests.RequestException, RuntimeError): + pass + print(f"Waiting for backend... (attempt {attempt}/{attempts})") + time.sleep(delay) + print( + f"Backend at {self.base_url} did not become ready after {attempts} attempts.", + file=sys.stderr, + ) + sys.exit(1) + + def fetch_existing_collections(self, organism: str) -> list[ExistingCollection]: + params = {"userId": self.user_id, "organism": organism} + r = requests.get(self._collections_url, params=params, timeout=10) + if not r.ok: + raise RuntimeError(f"GET /collections failed: {r.status_code} {r.text}") + return r.json() + + def create_collection(self, collection: Collection) -> int: + params = {"userId": self.user_id} + r = requests.post(self._collections_url, params=params, json=collection, timeout=10) + if r.status_code != 201: + raise RuntimeError(f"POST /collections failed: {r.status_code} {r.text}") + return r.json()["id"] + + def update_collection(self, collection_id: int, collection: Collection) -> None: + params = {"userId": self.user_id} + r = requests.put(f"{self._collections_url}/{collection_id}", params=params, json=collection, timeout=10) + if not r.ok: + raise RuntimeError(f"PUT /collections/{collection_id} failed: {r.status_code} {r.text}") diff --git a/collection-seeding/models.py b/collection-seeding/models.py new file mode 100644 index 000000000..2e0425561 --- /dev/null +++ b/collection-seeding/models.py @@ -0,0 +1,27 @@ +"""Shared type definitions for collection seeding.""" + +from typing import TypedDict + + +class FilterObject(TypedDict, total=False): + aminoAcidMutations: list[str] + nucleotideMutations: list[str] + + +class Variant(TypedDict): + type: str + name: str + filterObject: FilterObject + + +class Collection(TypedDict): + name: str + organism: str + description: str + variants: list[Variant] + + +class ExistingCollection(TypedDict): + """A collection as returned by the backend (includes the assigned id).""" + id: int + name: str diff --git a/collection-seeding/pixi.lock b/collection-seeding/pixi.lock new file mode 100644 index 000000000..b8ae0977c --- /dev/null +++ b/collection-seeding/pixi.lock @@ -0,0 +1,1135 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.13-h6add32d_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.0-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.1-h022381a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.2-h546c87b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.13-h11c0449_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.3-h25d91c4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.0-hcc62823_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.1-h8f8c405_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.2-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.13-h3d5d122_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.1-h1b79a29_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.13-h20e6be0_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + test: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.13-h6add32d_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.0-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.1-h022381a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.2-h546c87b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.13-h11c0449_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.3-h25d91c4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.0-hcc62823_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.1-h8f8c405_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.2-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.13-h3d5d122_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.1-h1b79a29_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.13-h20e6be0_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: a2527b1d81792a0ccd2c05850960df119c2b6d8f5fdec97f2db7d25dc23b1068 + md5: 468fd3bb9e1f671d36c2cbc677e56f1d + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 28926 + timestamp: 1770939656741 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + sha256: b3495077889dde6bb370938e7db82be545c73e8589696ad0843a32221520ad4c + md5: 840d8fc0d7b3209be93080bc20e07f2d + depends: + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 192412 + timestamp: 1771350241232 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + sha256: 9f242f13537ef1ce195f93f0cc162965d6cc79da578568d6d8e50f70dd025c42 + md5: 4173ac3b19ec0a4f400b4f782910368b + depends: + - __osx >=10.13 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 133427 + timestamp: 1771350680709 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 124834 + timestamp: 1771350416561 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + sha256: c9dbcc8039a52023660d6d1bbf87594a93dd69c6ac5a2a44323af2c92976728d + md5: e18ad67cf881dcadee8b8d9e2f8e5f73 + depends: + - __unix + license: ISC + purls: [] + size: 131039 + timestamp: 1776865545798 +- pypi: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + name: certifi + version: 2026.4.22 + sha256: 3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + name: charset-normalizer + version: 3.4.7 + sha256: 0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl + name: charset-normalizer + version: 3.4.7 + sha256: f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: charset-normalizer + version: 3.4.7 + sha256: e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd + requires_python: '>=3.7' +- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.3-h25d91c4_0.conda + sha256: 1294117122d55246bb83ad5b589e2a031aacdf2d0b1f99fd338aa4394f881735 + md5: 627eca44e62e2b665eeec57a984a7f00 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + size: 12273764 + timestamp: 1773822733780 +- pypi: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + name: idna + version: '3.13' + sha256: 892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 + requires_dist: + - ruff>=0.6.2 ; extra == 'all' + - mypy>=1.11.2 ; extra == 'all' + - pytest>=8.3.2 ; extra == 'all' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + name: iniconfig + version: 2.3.0 + sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + sha256: 7abd913d81a9bf00abb699e8987966baa2065f5132e37e815f92d90fc6bba530 + md5: a21644fc4a83da26452a718dc9468d5f + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-aarch64 2.45.1 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 875596 + timestamp: 1774197520746 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda + sha256: ea33c40977ea7a2c3658c522230058395bc2ee0d89d99f0711390b6a1ee80d12 + md5: a3b390520c563d78cc58974de95a03e5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.0.* + license: MIT + license_family: MIT + purls: [] + size: 77241 + timestamp: 1777846112704 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.0-hfae3067_0.conda + sha256: 206c422a7f4b462d1dc17d558f0299088d0992bd3309ae83f5440fcc4f130602 + md5: 3bacd6171f0a3f8fddd06c3d5ae01955 + depends: + - libgcc >=14 + constrains: + - expat 2.8.0.* + license: MIT + license_family: MIT + purls: [] + size: 76996 + timestamp: 1777846096032 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.0-hcc62823_0.conda + sha256: 5ebcc413d0a75da926a8b9b681d7d12c9562993991ba49c90a9881c4a59bdc11 + md5: d2e01f78c1daaeb4d2aa870125ebcd7e + depends: + - __osx >=11.0 + constrains: + - expat 2.8.0.* + license: MIT + license_family: MIT + purls: [] + size: 75242 + timestamp: 1777846416221 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.0-hf6b4638_0.conda + sha256: f4b1cafc59afaede8fa0a2d9cf376840f1c553001acd72f6ead18bbc8ac8c49c + md5: 65466e82c09e888ca7560c11a97d5450 + depends: + - __osx >=11.0 + constrains: + - expat 2.8.0.* + license: MIT + license_family: MIT + purls: [] + size: 68789 + timestamp: 1777846180142 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + sha256: 3df4c539449aabc3443bbe8c492c01d401eea894603087fca2917aa4e1c2dea9 + md5: 2f364feefb6a7c00423e80dcb12db62a + depends: + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 55952 + timestamp: 1769456078358 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + sha256: 951958d1792238006fdc6fce7f71f1b559534743b26cc1333497d46e5903a2d6 + md5: 66a0dc7464927d0853b590b6f53ba3ea + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + purls: [] + size: 53583 + timestamp: 1769456300951 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5 + md5: 0aa00f03f9e39fb9876085dee11a85d4 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 he0feb66_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 1041788 + timestamp: 1771378212382 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + sha256: 43df385bedc1cab11993c4369e1f3b04b4ca5d0ea16cba6a0e7f18dbc129fcc9 + md5: 552567ea2b61e3a3035759b2fdb3f9a6 + depends: + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 h8acb6b2_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 622900 + timestamp: 1771378128706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110 + md5: 239c5e9546c38a1e884d69effcf4c882 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 603262 + timestamp: 1771378117851 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + sha256: fc716f11a6a8525e27a5d332ef6a689210b0d2a4dd1133edc0f530659aa9faa6 + md5: 4faa39bf919939602e594253bd673958 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 588060 + timestamp: 1771378040807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda + sha256: d61962b9cd54c3554361550203c64d5b65b71e3058a285b66e4b04b9769f0a5c + md5: 76298a9e6d71ee6e832a8d0d7373b261 + depends: + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + size: 126102 + timestamp: 1775828008518 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + sha256: d9e2006051529aec5578c6efeb13bb6a7200a014b2d5a77a579e83a8049d5f3c + md5: becdfbfe7049fa248e52aa37a9df09e2 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + size: 105724 + timestamp: 1775826029494 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + size: 92472 + timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + sha256: 57c0dd12d506e84541c4e877898bd2a59cca141df493d34036f18b2751e0a453 + md5: 7b9813e885482e3ccb1fa212b86d7fd0 + depends: + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 114056 + timestamp: 1769482343003 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd + md5: ec88ba8a245855935b871a7324373105 + depends: + - __osx >=10.13 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 79899 + timestamp: 1769482558610 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda + sha256: 54cdcd3214313b62c2a8ee277e6f42150d9b748264c1b70d958bf735e420ef8d + md5: 7dc38adcbf71e6b38748e919e16e0dce + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + purls: [] + size: 954962 + timestamp: 1777986471789 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.1-h022381a_0.conda + sha256: ad03b7d8e4d08001f0df88ee7a56108bb35bae4795a42b9a04cc1abfa822bd07 + md5: 2ec1119217d8f0d086e9a62f3cb0e5ea + depends: + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + purls: [] + size: 955361 + timestamp: 1777986487553 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.1-h8f8c405_0.conda + sha256: 5e964e07a14180ce20decfd4897e8f81d48ec78c1cbf4af85c5520f535d9510c + md5: 9273c877f78b7486b0dfdd9268327a79 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + purls: [] + size: 1007171 + timestamp: 1777987093870 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.1-h1b79a29_0.conda + sha256: 49daec7c83e70d4efc17b813547824bc2bcf2f7256d84061d24fbfe537da9f74 + md5: 6681822ea9d362953206352371b6a904 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + purls: [] + size: 920047 + timestamp: 1777987051643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda + sha256: bc1b08c92626c91500fd9f26f2c797f3eb153b627d53e9c13cd167f1e12b2829 + md5: 38ffe67b78c9d4de527be8315e5ada2c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 40297 + timestamp: 1775052476770 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda + sha256: 7d427edf58c702c337bf62bc90f355b7fc374a65fd9f70ea7a490f13bb76b1b9 + md5: a0b5de740d01c390bdbb46d7503c9fab + depends: + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 43567 + timestamp: 1775052485727 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + sha256: eb111e32e5a7313a5bf799c7fb2419051fa2fe7eff74769fac8d5a448b309f7f + md5: 502006882cf5461adced436e410046d1 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 69833 + timestamp: 1774072605429 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + sha256: 4c6da089952b2d70150c74234679d6f7ac04f4a98f9432dec724968f912691e7 + md5: 30439ff30578e504ee5e0b390afc8c65 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 59000 + timestamp: 1774073052242 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 47759 + timestamp: 1774072956767 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + purls: [] + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda + sha256: 369db85c5cd8d99dde364ce70725d76511d9c8199e5b820c740414091bf5bcca + md5: b2a43456aa56fe80c2477a5094899eff + depends: + - libgcc >=14 + license: X11 AND BSD-3-Clause + purls: [] + size: 960036 + timestamp: 1777422174534 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + sha256: f5f7e006ff4271305ab4cc08eedd855c67a571793c3d18aff73f645f088a8cae + md5: 31b8740cf1b2588d4e61c81191004061 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + purls: [] + size: 831711 + timestamp: 1777423052277 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + purls: [] + size: 805509 + timestamp: 1777423252320 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda + sha256: c0ef482280e38c71a08ad6d71448194b719630345b0c9c60744a2010e8a8e0cb + md5: da1b85b6a87e141f5140bb9924cecab0 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3167099 + timestamp: 1775587756857 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.2-h546c87b_0.conda + sha256: 348cb74c1530ac241215d047ef65d134cf797af935c97a68655319362b7e6a01 + md5: 3b129669089e4d6a5c6871dbb4669b99 + depends: + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3706406 + timestamp: 1775589602258 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.2-hc881268_0.conda + sha256: 334fd49ea31b99114f5afb1ec44555dc8c90640648302a4f8f838ee345d1ec50 + md5: 5cf0ece4375c73d7a5765e83565a69c7 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + purls: [] + size: 2776564 + timestamp: 1775589970694 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.conda + sha256: c91bf510c130a1ea1b6ff023e28bac0ccaef869446acd805e2016f69ebdc49ea + md5: 25dcccd4f80f1638428613e0d7c9b4e1 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3106008 + timestamp: 1775587972483 +- pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl + name: packaging + version: '26.2' + sha256: 5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + name: pluggy + version: 1.6.0 + sha256: e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + requires_dist: + - pre-commit ; extra == 'dev' + - tox ; extra == 'dev' + - pytest ; extra == 'testing' + - pytest-benchmark ; extra == 'testing' + - coverage ; extra == 'testing' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + name: pygments + version: 2.20.0 + sha256: 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + requires_dist: + - colorama>=0.4.6 ; extra == 'windows-terminal' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + name: pytest + version: 9.0.3 + sha256: 2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 + requires_dist: + - colorama>=0.4 ; sys_platform == 'win32' + - exceptiongroup>=1 ; python_full_version < '3.11' + - iniconfig>=1.0.1 + - packaging>=22 + - pluggy>=1.5,<2 + - pygments>=2.7.2 + - tomli>=1 ; python_full_version < '3.11' + - argcomplete ; extra == 'dev' + - attrs>=19.2 ; extra == 'dev' + - hypothesis>=3.56 ; extra == 'dev' + - mock ; extra == 'dev' + - requests ; extra == 'dev' + - setuptools ; extra == 'dev' + - xmlschema ; extra == 'dev' + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.13-h6add32d_100_cp313.conda + build_number: 100 + sha256: 7f77eb57648f545c1f58e10035d0d9d66b0a0efb7c4b58d3ed89ec7269afdde1 + md5: 05051be49267378d2fcd12931e319ac3 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.5,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.52.0,<4.0a0 + - libuuid >=2.42,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.6,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + purls: [] + size: 37358322 + timestamp: 1775614712638 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.13-h11c0449_100_cp313.conda + build_number: 100 + sha256: d14e731e871d6379f8b82f3af5eb3382caa444880a9fc9d1d12033748277eb14 + md5: 81809cabd4647dee1127f2623a6a3005 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.7.5,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.52.0,<4.0a0 + - libuuid >=2.42,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.6,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + purls: [] + size: 34042952 + timestamp: 1775613691 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.13-h3d5d122_100_cp313.conda + build_number: 100 + sha256: 6f71b48fe93ebc0dd42c80358b75020f6ad12ed4772fb3555da36000139c0dc7 + md5: 8948c8c7c653ad668d55bbbd6836178b + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.5,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.52.0,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.6,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + purls: [] + size: 17650454 + timestamp: 1775616128232 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.13-h20e6be0_100_cp313.conda + build_number: 100 + sha256: d0fffc5fde21d1ae350da545dfb9e115a8c53bed8a9c5761f9efd4a5581853c1 + md5: 9991a930e81d3873eba7a299ba783ec4 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.5,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.52.0,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.6,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + purls: [] + size: 12966447 + timestamp: 1775615694085 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + build_number: 8 + sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 + md5: 94305520c52a4aa3f6c2b1ff6008d9f8 + constrains: + - python 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 7002 + timestamp: 1752805902938 +- pypi: https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + name: pyyaml + version: 6.0.3 + sha256: ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: pyyaml + version: 6.0.3 + sha256: 0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl + name: pyyaml + version: 6.0.3 + sha256: 2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl + name: pyyaml + version: 6.0.3 + sha256: 8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 + md5: 3d49cad61f829f4f0e0611547a9cda12 + depends: + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 357597 + timestamp: 1765815673644 +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + sha256: 4614af680aa0920e82b953fece85a03007e0719c3399f13d7de64176874b80d5 + md5: eefd65452dfe7cce476a519bece46704 + depends: + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 317819 + timestamp: 1765813692798 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 313930 + timestamp: 1765813902568 +- pypi: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + name: requests + version: 2.33.1 + sha256: 4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a + requires_dist: + - charset-normalizer>=2,<4 + - idna>=2.5,<4 + - urllib3>=1.26,<3 + - certifi>=2023.5.7 + - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' + - chardet>=3.0.2,<8 ; extra == 'use-chardet-on-py3' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl + name: responses + version: 0.26.0 + sha256: 03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37 + requires_dist: + - requests>=2.30.0,<3.0 + - urllib3>=1.25.10,<3.0 + - pyyaml + - pytest>=7.0.0 ; extra == 'tests' + - coverage>=6.0.0 ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-asyncio ; extra == 'tests' + - pytest-httpserver ; extra == 'tests' + - flake8 ; extra == 'tests' + - types-pyyaml ; extra == 'tests' + - types-requests ; extra == 'tests' + - mypy ; extra == 'tests' + - tomli ; python_full_version < '3.11' and extra == 'tests' + - tomli-w ; extra == 'tests' + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + sha256: e25c314b52764219f842b41aea2c98a059f06437392268f09b03561e4f6e5309 + md5: 7fc6affb9b01e567d2ef1d05b84aa6ed + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3368666 + timestamp: 1769464148928 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + sha256: 7f0d9c320288532873e2d8486c331ec6d87919c9028208d3f6ac91dc8f99a67b + md5: 6e6efb7463f8cef69dbcb4c2205bf60e + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3282953 + timestamp: 1769460532442 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3127137 + timestamp: 1769460817696 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + purls: [] + size: 119135 + timestamp: 1767016325805 +- pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + name: urllib3 + version: 2.6.3 + sha256: bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + requires_dist: + - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' + - h2>=4,<5 ; extra == 'h2' + - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks' + - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd' + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 + md5: c3655f82dcea2aa179b291e7099c1fcc + depends: + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 614429 + timestamp: 1764777145593 diff --git a/collection-seeding/pixi.toml b/collection-seeding/pixi.toml new file mode 100644 index 000000000..7b6794266 --- /dev/null +++ b/collection-seeding/pixi.toml @@ -0,0 +1,27 @@ +[workspace] +name = "example-data-seeder" +version = "0.1.0" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-arm64", "osx-64", "linux-aarch64"] + +[dependencies] +python = "3.13.*" + +[pypi-dependencies] +requests = "*" + +[tasks] +seed = "python seed.py" +seed-lineages = "python seed.py covid-pango-lineages" +seed-all-lineages = "python seed.py covid-pango-lineages --limit 0" +seed-resistance = "python seed.py covid-resistance-mutations" + +[feature.test.pypi-dependencies] +pytest = "*" +responses = "*" + +[feature.test.tasks] +test = "pytest" + +[environments] +test = { features = ["test"] } diff --git a/collection-seeding/pytest.ini b/collection-seeding/pytest.ini new file mode 100644 index 000000000..c7b23ecb1 --- /dev/null +++ b/collection-seeding/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/example-data/seed.mjs b/collection-seeding/seed.mjs similarity index 100% rename from example-data/seed.mjs rename to collection-seeding/seed.mjs diff --git a/collection-seeding/seed.py b/collection-seeding/seed.py new file mode 100644 index 000000000..764bb126e --- /dev/null +++ b/collection-seeding/seed.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Seeds example collections into the backend from one or more data sources. + +Idempotent: skips any collection whose name already exists for the seed user. + +Run with --help for usage, or --help for source-specific options. +""" + +import argparse +import os +import sys + +from backend import BackendClient +from models import Collection +from sources import pango_lineages, resistance_mutations + +ALL_SOURCES = [resistance_mutations, pango_lineages] +DEFAULT_LINEAGE_LIMIT = 10 + + +def make_parser() -> argparse.ArgumentParser: + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument( + "-u", "--url", + default=os.environ.get("BACKEND_URL", "http://localhost:8080"), + help="Backend base URL (default: $BACKEND_URL or http://localhost:8080)", + ) + parent.add_argument( + "--wait", + action="store_true", + default=not sys.stdout.isatty(), + help="Retry until backend is ready (auto-enabled when no TTY)", + ) + + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + parents=[parent], + ) + subparsers = parser.add_subparsers(dest="source", metavar="source") + + subparsers.add_parser( + resistance_mutations.NAME, + parents=[parent], + help="Seed SARS-CoV-2 antiviral resistance mutation collections", + ) + + lineages_parser = subparsers.add_parser( + pango_lineages.NAME, + parents=[parent], + help="Seed pango lineage collections", + ) + lineages_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_LINEAGE_LIMIT, + metavar="N", + help=f"Only process the first N lineages (default: {DEFAULT_LINEAGE_LIMIT}; 0 = all)", + ) + + return parser + + +def seed_source(client: BackendClient, source_name: str, collections: list[Collection]) -> tuple[int, int]: + print(f"\n[{source_name}]") + + organisms = {} + for c in collections: + organisms.setdefault(c["organism"], []).append(c) + + created = 0 + updated = 0 + for organism, org_collections in organisms.items(): + existing = client.fetch_existing_collections(organism) + existing_by_name = {c["name"]: c for c in existing} + for collection in org_collections: + existing_entry = existing_by_name.get(collection["name"]) + if existing_entry: + client.update_collection(existing_entry["id"], collection) + print(f" UPDATE id={existing_entry['id']} {collection['name']}") + updated += 1 + else: + col_id = client.create_collection(collection) + print(f" CREATE id={col_id} {collection['name']}") + created += 1 + + print(f" Created: {created}, updated: {updated}.") + return created, updated + + +def main(): + parser = make_parser() + args = parser.parse_args() + + client = BackendClient(args.url) + print(f"Seeding collections against {args.url} ...") + + if args.wait: + client.wait_for_backend() # syncs user as part of polling + else: + client.sync_user() + + print(f"Seeding as user id={client.user_id}.") + + lineage_limit = getattr(args, "limit", DEFAULT_LINEAGE_LIMIT) + + if args.source == resistance_mutations.NAME: + active = [(resistance_mutations, {})] + elif args.source == pango_lineages.NAME: + active = [(pango_lineages, {"limit": lineage_limit})] + else: + # No subcommand: run all sources + active = [ + (resistance_mutations, {}), + (pango_lineages, {"limit": lineage_limit}), + ] + + total_created = 0 + total_updated = 0 + for source, kwargs in active: + collections = source.get_collections(**kwargs) + c, u = seed_source(client, source.NAME, collections) + total_created += c + total_updated += u + + if len(active) > 1: + print(f"\nTotal — created: {total_created}, updated: {total_updated}.") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/collection-seeding/sources/__init__.py b/collection-seeding/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/collection-seeding/sources/pango_lineages.py b/collection-seeding/sources/pango_lineages.py new file mode 100644 index 000000000..85c520252 --- /dev/null +++ b/collection-seeding/sources/pango_lineages.py @@ -0,0 +1,60 @@ +"""Source: Pango lineage definitions from corneliusroemer/pango-sequences. + +Creates one collection per lineage, with nucleotide substitutions as variants. +""" + +import requests + +from models import Collection, Variant + +NAME = "covid-pango-lineages" + +DATA_URL = ( + "https://raw.githubusercontent.com/corneliusroemer/pango-sequences" + "/refs/heads/main/data/pango-consensus-sequences_summary.json" +) + + +def _build_collection(entry: dict) -> Collection: + lineage: str = entry["lineage"] + parent: str = entry.get("parent") or "—" + clade: str = entry.get("nextstrainClade") or "—" + date: str = entry.get("designationDate") or "unknown" + + subs = [s for s in entry.get("nucSubstitutions", []) if s] + variants: list[Variant] = [ + { + "type": "filterObject", + "name": sub, + "filterObject": {"nucleotideMutations": [sub]}, + } + for sub in subs + ] + + description = ( + f"Pango lineage {lineage}. " + f"Parent: {parent}. " + f"Nextstrain clade: {clade}. " + f"Designated: {date}." + ) + + return Collection( + name=lineage, + organism="covid", + description=description, + variants=variants, + ) + + +def get_collections(limit: int = 0) -> list[Collection]: + print(f"Fetching lineage data from {DATA_URL} ...") + response = requests.get(DATA_URL, timeout=60) + response.raise_for_status() + entries = list(response.json().values()) + if limit: + entries = entries[:limit] + print(f" Loaded {len(entries)} lineage(s).") + + collections = [_build_collection(e) for e in entries] + # Drop lineages that ended up with no variants after filtering blank subs + return [c for c in collections if c["variants"]] diff --git a/collection-seeding/sources/resistance_mutations.py b/collection-seeding/sources/resistance_mutations.py new file mode 100644 index 000000000..82e3b9b2d --- /dev/null +++ b/collection-seeding/sources/resistance_mutations.py @@ -0,0 +1,138 @@ +"""Source: SARS-CoV-2 antiviral resistance mutations (ported from seed.mjs). + +Three collections covering 3CLpro, RdRp, and Spike mAb resistance mutations +as per the Stanford Coronavirus Antiviral & Resistance database. +""" + +from models import Collection, Variant + +NAME = "covid-resistance-mutations" + +CLPRO_MUTATIONS = [ + 'ORF1a:T3284I', 'ORF1a:T3288A', 'ORF1a:T3288N', 'ORF1a:T3308I', 'ORF1a:D3311Y', + 'ORF1a:M3312I', 'ORF1a:M3312L', 'ORF1a:M3312T', 'ORF1a:M3312-', 'ORF1a:L3313F', + 'ORF1a:G3401S', 'ORF1a:F3403L', 'ORF1a:F3403S', 'ORF1a:N3405D', 'ORF1a:N3405L', + 'ORF1a:N3405S', 'ORF1a:G3406S', 'ORF1a:S3407A', 'ORF1a:S3407E', 'ORF1a:S3407L', + 'ORF1a:S3407P', 'ORF1a:C3423F', 'ORF1a:M3428R', 'ORF1a:M3428T', 'ORF1a:E3429A', + 'ORF1a:E3429G', 'ORF1a:E3429K', 'ORF1a:E3429Q', 'ORF1a:E3429V', 'ORF1a:L3430F', + 'ORF1a:P3431-', 'ORF1a:T3432I', 'ORF1a:H3435L', 'ORF1a:H3435N', 'ORF1a:H3435Q', + 'ORF1a:H3435Y', 'ORF1a:A3436T', 'ORF1a:A3436V', 'ORF1a:V3449A', 'ORF1a:R3451G', + 'ORF1a:R3451S', 'ORF1a:Q3452I', 'ORF1a:Q3452K', 'ORF1a:T3453I', 'ORF1a:A3454T', + 'ORF1a:A3454V', 'ORF1a:Q3455A', 'ORF1a:Q3455C', 'ORF1a:Q3455D', 'ORF1a:Q3455E', + 'ORF1a:Q3455F', 'ORF1a:Q3455G', 'ORF1a:Q3455H', 'ORF1a:Q3455I', 'ORF1a:Q3455K', + 'ORF1a:Q3455L', 'ORF1a:Q3455N', 'ORF1a:Q3455P', 'ORF1a:Q3455R', 'ORF1a:Q3455S', + 'ORF1a:Q3455T', 'ORF1a:Q3455V', 'ORF1a:Q3455W', 'ORF1a:Q3455Y', 'ORF1a:A3456P', + 'ORF1a:A3457S', 'ORF1a:P3515L', 'ORF1a:V3560A', 'ORF1a:S3564P', 'ORF1a:T3567I', + 'ORF1a:F3568L', +] + +RDRP_MUTATIONS = [ + 'ORF1b:V157A', 'ORF1b:V157L', 'ORF1b:N189S', 'ORF1b:R276C', 'ORF1b:A367V', + 'ORF1b:A440V', 'ORF1b:F471L', 'ORF1b:D475Y', 'ORF1b:A517V', 'ORF1b:V548L', + 'ORF1b:G662S', 'ORF1b:S750A', 'ORF1b:V783I', 'ORF1b:E787G', 'ORF1b:C790F', + 'ORF1b:C790R', 'ORF1b:E793A', 'ORF1b:E793D', 'ORF1b:M915R', +] + +SPIKE_MUTATIONS = [ + 'S:P337H', 'S:P337L', 'S:P337R', 'S:P337S', 'S:P337T', + 'S:E340A', 'S:E340D', 'S:E340G', 'S:E340K', 'S:E340Q', 'S:E340V', + 'S:T345P', + 'S:R346G', 'S:R346I', 'S:R346K', 'S:R346S', 'S:R346T', + 'S:K356Q', 'S:K356T', + 'S:S371F', 'S:S371L', + 'S:D405E', 'S:D405N', 'S:E406D', + 'S:K417E', 'S:K417H', 'S:K417I', 'S:K417M', 'S:K417N', 'S:K417R', 'S:K417S', 'S:K417T', + 'S:D420A', 'S:D420N', + 'S:N439K', + 'S:N440D', 'S:N440E', 'S:N440I', 'S:N440K', 'S:N440R', 'S:N440T', 'S:N440Y', + 'S:S443Y', + 'S:K444E', 'S:K444F', 'S:K444I', 'S:K444L', 'S:K444M', 'S:K444N', 'S:K444R', 'S:K444T', + 'S:V445A', 'S:V445D', 'S:V445F', 'S:V445I', 'S:V445L', + 'S:G446A', 'S:G446D', 'S:G446I', 'S:G446N', 'S:G446R', 'S:G446S', 'S:G446T', 'S:G446V', + 'S:G447C', 'S:G447D', 'S:G447F', 'S:G447S', 'S:G447V', + 'S:N448D', 'S:N448K', 'S:N448T', 'S:N448Y', + 'S:Y449D', + 'S:N450D', 'S:N450K', + 'S:L452M', 'S:L452Q', 'S:L452R', 'S:L452W', + 'S:Y453F', 'S:Y453H', + 'S:L455F', 'S:L455M', 'S:L455S', 'S:L455W', + 'S:F456C', 'S:F456L', 'S:F456V', + 'S:S459P', + 'S:N460D', 'S:N460H', 'S:N460I', 'S:N460K', 'S:N460S', 'S:N460T', 'S:N460Y', + 'S:A475D', 'S:A475V', + 'S:G476D', 'S:G476R', 'S:G476T', + 'S:V483A', + 'S:E484A', 'S:E484D', 'S:E484G', 'S:E484K', 'S:E484P', 'S:E484Q', 'S:E484R', 'S:E484S', 'S:E484T', 'S:E484V', + 'S:G485D', 'S:G485R', + 'S:F486D', 'S:F486I', 'S:F486L', 'S:F486N', 'S:F486P', 'S:F486S', 'S:F486T', 'S:F486V', + 'S:N487D', 'S:N487H', 'S:N487S', + 'S:Y489H', 'S:Y489W', + 'S:F490G', 'S:F490I', 'S:F490L', 'S:F490R', 'S:F490S', 'S:F490V', 'S:F490Y', + 'S:Q493D', 'S:Q493E', 'S:Q493H', 'S:Q493K', 'S:Q493L', 'S:Q493R', 'S:Q493V', + 'S:S494P', 'S:S494R', + 'S:G496S', + 'S:Q498H', + 'S:P499H', 'S:P499R', 'S:P499S', 'S:P499T', + 'S:N501T', 'S:N501Y', + 'S:G504C', 'S:G504D', 'S:G504I', 'S:G504L', 'S:G504N', 'S:G504R', 'S:G504V', + 'S:P507A', + 'S:N856K', 'S:N969K', 'S:E990A', 'S:T1009I', +] + + +def _mature_name(mutation: str, set_name: str, offset: int) -> str: + """Convert a genomic mutation code to a mature protein name with the given offset. + + e.g. _mature_name("ORF1a:T3284I", "3CLpro", -3263) -> "3CLpro:T21I" + """ + mut_part = mutation[mutation.index(':') + 1:] + original_base = mut_part[0] + new_base = mut_part[-1] + position = int(''.join(c for c in mut_part if c.isdigit())) + return f"{set_name}:{original_base}{position + offset}{new_base}" + + +def _build_variants(mutations: list[str], set_name: str, offset: int) -> list[Variant]: + return [ + { + "type": "filterObject", + "name": _mature_name(m, set_name, offset), + "filterObject": {"aminoAcidMutations": [m]}, + } + for m in mutations + ] + + +def get_collections(limit: int = 0) -> list[Collection]: + return [ + { + "name": "3CLpro resistance mutations", + "organism": "covid", + "description": ( + "SARS-CoV-2 3C-like protease (3CLpro/Mpro) inhibitor resistance mutations " + "as per Stanford Coronavirus Antiviral & Resistance database " + "(last updated 21 August 2024)." + ), + "variants": _build_variants(CLPRO_MUTATIONS, "3CLpro", -3263), + }, + { + "name": "RdRp resistance mutations", + "organism": "covid", + "description": ( + "SARS-CoV-2 RNA-dependent RNA polymerase (RdRp) inhibitor resistance mutations " + "as per Stanford Coronavirus Antiviral & Resistance database " + "(last updated 21 August 2024)." + ), + "variants": _build_variants(RDRP_MUTATIONS, "RdRp", 9), + }, + { + "name": "Spike mAb resistance mutations", + "organism": "covid", + "description": ( + "SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutations " + "as per Stanford Coronavirus Antiviral & Resistance database " + "(last updated 21 August 2024)." + ), + "variants": _build_variants(SPIKE_MUTATIONS, "Spike", 0), + }, + ] diff --git a/collection-seeding/tests/__init__.py b/collection-seeding/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/collection-seeding/tests/mock_source.py b/collection-seeding/tests/mock_source.py new file mode 100644 index 000000000..4b17db6b4 --- /dev/null +++ b/collection-seeding/tests/mock_source.py @@ -0,0 +1,22 @@ +"""Mock data source for use in tests.""" + +NAME = "mock-source" + +COLLECTIONS = [ + { + "name": "Mock Collection A", + "organism": "covid", + "description": "A mock collection for testing.", + "variants": [{"type": "filterObject", "name": "C123T", "filterObject": {"nucleotideMutations": ["C123T"]}}], + }, + { + "name": "Mock Collection B", + "organism": "covid", + "description": "Another mock collection for testing.", + "variants": [], + }, +] + + +def get_collections(limit: int = 0) -> list[dict]: + return list(COLLECTIONS) diff --git a/collection-seeding/tests/test_backend.py b/collection-seeding/tests/test_backend.py new file mode 100644 index 000000000..cad1fd6cf --- /dev/null +++ b/collection-seeding/tests/test_backend.py @@ -0,0 +1,136 @@ +import sys +import pytest +import responses as rsps_lib + +from backend import BackendClient, SYNC_GITHUB_ID, SYNC_NAME + +BASE = "http://localhost:8080" +SYNC_URL = f"{BASE}/users/sync" +COLLECTIONS_URL = f"{BASE}/collections" + + +@pytest.fixture +def client(): + return BackendClient(BASE) + + +# --- sync_user --- + +@rsps_lib.activate +def test_sync_user_sets_user_id(client): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={"id": 42}, status=200) + result = client.sync_user() + assert result == 42 + assert client.user_id == 42 + + +@rsps_lib.activate +def test_sync_user_sends_correct_body(client): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={"id": 1}, status=200) + client.sync_user() + body = rsps_lib.calls[0].request.body + import json + parsed = json.loads(body) + assert parsed["githubId"] == SYNC_GITHUB_ID + assert parsed["name"] == SYNC_NAME + + +@rsps_lib.activate +def test_sync_user_raises_on_error(client): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={"error": "bad"}, status=500) + with pytest.raises(RuntimeError, match="POST /users/sync failed"): + client.sync_user() + + +# --- fetch_existing_collections --- + +@rsps_lib.activate +def test_fetch_existing_collections(client): + client.user_id = 7 + existing = [{"id": 1, "name": "Col A"}, {"id": 2, "name": "Col B"}] + rsps_lib.add(rsps_lib.GET, COLLECTIONS_URL, json=existing, status=200) + + result = client.fetch_existing_collections("covid") + + assert result == existing + req = rsps_lib.calls[0].request + assert "userId=7" in req.url + assert "organism=covid" in req.url + + +@rsps_lib.activate +def test_fetch_existing_collections_raises_on_error(client): + client.user_id = 7 + rsps_lib.add(rsps_lib.GET, COLLECTIONS_URL, json={}, status=500) + with pytest.raises(RuntimeError, match="GET /collections failed"): + client.fetch_existing_collections("covid") + + +# --- create_collection --- + +@rsps_lib.activate +def test_create_collection_returns_id(client): + client.user_id = 7 + rsps_lib.add(rsps_lib.POST, COLLECTIONS_URL, json={"id": 99}, status=201) + col = {"name": "Test", "organism": "covid", "description": "", "variants": []} + result = client.create_collection(col) + assert result == 99 + + +@rsps_lib.activate +def test_create_collection_raises_on_non_201(client): + client.user_id = 7 + rsps_lib.add(rsps_lib.POST, COLLECTIONS_URL, json={}, status=200) + with pytest.raises(RuntimeError, match="POST /collections failed"): + client.create_collection({"name": "X", "organism": "covid", "description": "", "variants": []}) + + +# --- update_collection --- + +@rsps_lib.activate +def test_update_collection_puts_correct_url(client): + client.user_id = 7 + rsps_lib.add(rsps_lib.PUT, f"{COLLECTIONS_URL}/55", json={}, status=200) + col = {"name": "Updated", "organism": "covid", "description": "", "variants": []} + client.update_collection(55, col) + + req = rsps_lib.calls[0].request + assert "/collections/55" in req.url + assert "userId=7" in req.url + + +@rsps_lib.activate +def test_update_collection_raises_on_error(client): + client.user_id = 7 + rsps_lib.add(rsps_lib.PUT, f"{COLLECTIONS_URL}/55", json={}, status=404) + with pytest.raises(RuntimeError, match="PUT /collections/55 failed"): + client.update_collection(55, {"name": "X", "organism": "covid", "description": "", "variants": []}) + + +# --- wait_for_backend --- + +@rsps_lib.activate +def test_wait_for_backend_succeeds_immediately(client): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={"id": 5}, status=200) + client.wait_for_backend() + assert client.user_id == 5 + assert len(rsps_lib.calls) == 1 + + +@rsps_lib.activate +def test_wait_for_backend_retries_then_succeeds(client): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={}, status=500) + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={}, status=500) + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={"id": 8}, status=200) + client.wait_for_backend(attempts=5, delay=0) + assert client.user_id == 8 + assert len(rsps_lib.calls) == 3 + + +@rsps_lib.activate +def test_wait_for_backend_exits_after_max_attempts(client): + for _ in range(3): + rsps_lib.add(rsps_lib.POST, SYNC_URL, json={}, status=500) + with pytest.raises(SystemExit) as exc: + client.wait_for_backend(attempts=3, delay=0) + assert exc.value.code == 1 diff --git a/collection-seeding/tests/test_pango_lineages.py b/collection-seeding/tests/test_pango_lineages.py new file mode 100644 index 000000000..5820ae998 --- /dev/null +++ b/collection-seeding/tests/test_pango_lineages.py @@ -0,0 +1,108 @@ +import json +import responses as rsps_lib + +from sources.pango_lineages import _build_collection, get_collections, DATA_URL, NAME + +SAMPLE_DATA = { + "BA.2": { + "lineage": "BA.2", + "unaliased": "B.1.1.529.2", + "parent": "BA", + "nextstrainClade": "22C", + "nucSubstitutions": ["C241T", "A23403G", ""], + "designationDate": "2022-01-20", + }, + "XBB": { + "lineage": "XBB", + "unaliased": "XBB", + "parent": "", + "nextstrainClade": "", + "nucSubstitutions": [""], + "designationDate": "", + }, + "BA.5": { + "lineage": "BA.5", + "unaliased": "B.1.1.529.5", + "parent": "BA", + "nextstrainClade": "22B", + "nucSubstitutions": ["C241T", "T19955C"], + "designationDate": "2022-05-06", + }, +} + + +def test_name(): + assert NAME == "covid-pango-lineages" + + +# --- _build_collection --- + +def test_build_collection_basic(): + col = _build_collection(SAMPLE_DATA["BA.2"]) + assert col["name"] == "BA.2" + assert col["organism"] == "covid" + + +def test_build_collection_description_format(): + col = _build_collection(SAMPLE_DATA["BA.2"]) + assert "BA.2" in col["description"] + assert "BA" in col["description"] # parent + assert "22C" in col["description"] # clade + assert "2022-01-20" in col["description"] + + +def test_build_collection_filters_blank_subs(): + col = _build_collection(SAMPLE_DATA["BA.2"]) + # nucSubstitutions has ["C241T", "A23403G", ""] — blank should be dropped + assert len(col["variants"]) == 2 + names = [v["name"] for v in col["variants"]] + assert "C241T" in names + assert "A23403G" in names + + +def test_build_collection_variant_structure(): + col = _build_collection(SAMPLE_DATA["BA.2"]) + for v in col["variants"]: + assert v["type"] == "filterObject" + assert "nucleotideMutations" in v["filterObject"] + assert len(v["filterObject"]["nucleotideMutations"]) == 1 + + +def test_build_collection_missing_fields_use_defaults(): + col = _build_collection(SAMPLE_DATA["XBB"]) + assert "—" in col["description"] # parent and clade fallback + assert "unknown" in col["description"] # date fallback + + +# --- get_collections --- + +@rsps_lib.activate +def test_get_collections_fetches_data_url(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + get_collections() + assert len(rsps_lib.calls) == 1 + assert rsps_lib.calls[0].request.url == DATA_URL + + +@rsps_lib.activate +def test_get_collections_excludes_empty_variants(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + cols = get_collections() + # XBB has only blank subs → should be excluded + names = [c["name"] for c in cols] + assert "XBB" not in names + + +@rsps_lib.activate +def test_get_collections_respects_limit(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + cols = get_collections(limit=1) + assert len(cols) <= 1 + + +@rsps_lib.activate +def test_get_collections_no_limit_returns_all_valid(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + cols = get_collections(limit=0) + # BA.2 and BA.5 have valid subs; XBB does not + assert len(cols) == 2 diff --git a/collection-seeding/tests/test_resistance_mutations.py b/collection-seeding/tests/test_resistance_mutations.py new file mode 100644 index 000000000..cfa48c234 --- /dev/null +++ b/collection-seeding/tests/test_resistance_mutations.py @@ -0,0 +1,51 @@ +from sources.resistance_mutations import _mature_name, get_collections, NAME + + +def test_name(): + assert NAME == "covid-resistance-mutations" + + +# --- _mature_name --- + +def test_mature_name_clpro_offset(): + # ORF1a position 3284, offset -3263 → position 21 + assert _mature_name("ORF1a:T3284I", "3CLpro", -3263) == "3CLpro:T21I" + + +def test_mature_name_rdrp_offset(): + # ORF1b position 157, offset +9 → position 166 + assert _mature_name("ORF1b:V157A", "RdRp", 9) == "RdRp:V166A" + + +def test_mature_name_spike_zero_offset(): + assert _mature_name("S:E484K", "Spike", 0) == "Spike:E484K" + + +def test_mature_name_deletion(): + # Deletions use '-' as new base + assert _mature_name("ORF1a:M3312-", "3CLpro", -3263) == "3CLpro:M49-" + + +# --- get_collections --- + +def test_get_collections_returns_three(): + cols = get_collections() + assert len(cols) == 3 + + +def test_get_collections_all_covid(): + for col in get_collections(): + assert col["organism"] == "covid" + + +def test_get_collections_variant_structure(): + for col in get_collections(): + assert col["variants"], f"'{col['name']}' has no variants" + for v in col["variants"]: + assert v["type"] == "filterObject" + assert "aminoAcidMutations" in v["filterObject"] + assert len(v["filterObject"]["aminoAcidMutations"]) == 1 + + +def test_get_collections_limit_ignored(): + assert get_collections(limit=1) == get_collections(limit=0) diff --git a/collection-seeding/tests/test_seed.py b/collection-seeding/tests/test_seed.py new file mode 100644 index 000000000..139d66d4f --- /dev/null +++ b/collection-seeding/tests/test_seed.py @@ -0,0 +1,74 @@ +from unittest.mock import MagicMock, call + +from seed import seed_source +from tests.mock_source import COLLECTIONS + + +def make_client(existing=None): + client = MagicMock() + client.fetch_existing_collections.return_value = existing or [] + client.create_collection.return_value = 99 + return client + + +# --- seed_source: create / update / mixed --- + +def test_all_new_creates_all(): + client = make_client(existing=[]) + created, updated = seed_source(client, "mock-source", list(COLLECTIONS)) + assert created == len(COLLECTIONS) + assert updated == 0 + assert client.create_collection.call_count == len(COLLECTIONS) + client.update_collection.assert_not_called() + + +def test_all_existing_updates_all(): + existing = [{"id": i + 1, "name": c["name"]} for i, c in enumerate(COLLECTIONS)] + client = make_client(existing=existing) + created, updated = seed_source(client, "mock-source", list(COLLECTIONS)) + assert created == 0 + assert updated == len(COLLECTIONS) + assert client.update_collection.call_count == len(COLLECTIONS) + client.create_collection.assert_not_called() + + +def test_mixed_creates_and_updates(): + # Only the first collection already exists + existing = [{"id": 10, "name": COLLECTIONS[0]["name"]}] + client = make_client(existing=existing) + created, updated = seed_source(client, "mock-source", list(COLLECTIONS)) + assert created == len(COLLECTIONS) - 1 + assert updated == 1 + + +def test_update_uses_correct_id(): + existing = [{"id": 42, "name": COLLECTIONS[0]["name"]}] + client = make_client(existing=existing) + seed_source(client, "mock-source", [COLLECTIONS[0]]) + client.update_collection.assert_called_once_with(42, COLLECTIONS[0]) + + +def test_create_passes_full_collection(): + client = make_client(existing=[]) + seed_source(client, "mock-source", [COLLECTIONS[0]]) + client.create_collection.assert_called_once_with(COLLECTIONS[0]) + + +def test_fetch_called_once_per_organism(): + # Two collections with different organisms + multi = [ + {**COLLECTIONS[0], "organism": "covid"}, + {**COLLECTIONS[1], "organism": "mpox"}, + ] + client = make_client(existing=[]) + seed_source(client, "mock-source", multi) + assert client.fetch_existing_collections.call_count == 2 + organisms_fetched = {c.args[0] for c in client.fetch_existing_collections.call_args_list} + assert organisms_fetched == {"covid", "mpox"} + + +def test_returns_zero_counts_for_empty_collections(): + client = make_client(existing=[]) + created, updated = seed_source(client, "mock-source", []) + assert created == 0 + assert updated == 0 diff --git a/docker-compose.yml b/docker-compose.yml index a5c499de7..3af0825fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,6 @@ services: - backend environment: BACKEND_URL: http://backend:8080 - SEED_USER_ID: example-data-seeder restart: "no" volumes: diff --git a/example-data/Dockerfile b/example-data/Dockerfile deleted file mode 100644 index 4ebbbed8e..000000000 --- a/example-data/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM node:24-alpine -WORKDIR /app -COPY seed.mjs . -CMD ["node", "seed.mjs"] diff --git a/example-data/README.md b/example-data/README.md deleted file mode 100644 index 3c33232c0..000000000 --- a/example-data/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# example-data - -Seeds the backend with example collections (resistance mutation data for 3CLpro, RdRp, and Spike mAb). - -The script is idempotent — re-running it will skip collections that already exist. - -## Via Docker Compose - -The seeder runs automatically as part of Docker Compose: - -```bash -BACKEND_TAG=latest WEBSITE_TAG=latest SEEDER_TAG=latest docker compose up -``` - -## Running locally by hand - -Requires a current version of NodeJS. No `npm install` needed. - -```bash -# Local backend running on :8080 -node seed.mjs - -# Local backend on a different port -node seed.mjs --url http://localhost:9021 -``` - -Run `node seed.mjs --help` for all options.