diff --git a/.github/workflows/example-data-seeder.yml b/.github/workflows/example-data-seeder.yml index 1b06a9f8c..c759a77e3 100644 --- a/.github/workflows/example-data-seeder.yml +++ b/.github/workflows/example-data-seeder.yml @@ -6,8 +6,24 @@ env: DOCKER_IMAGE_NAME: ghcr.io/genspectrum/dashboards/example-data-seeder jobs: + test: + name: Test Example Data Seeder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: prefix-dev/setup-pixi@v0.9.6 + with: + manifest-path: collection-seeding/pixi.toml + environments: test + + - name: Run tests + working-directory: ./collection-seeding + run: pixi run -e test test + dockerImage: name: Build Example Data Seeder Docker Image + needs: test runs-on: ubuntu-latest permissions: contents: read @@ -37,7 +53,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/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index b11e366df..d596c76a1 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -24,16 +24,19 @@ class CollectionsController(private val collectionModel: CollectionModel) { @Operation( summary = "Get collections", description = "Returns collections filtered by optional userId and/or organism parameters. " + - "Set includeVariants=true to include the full variant list; by default only variantCount is returned.", + "Set includeVariants=true to include the full variant list; by default only variantCount is returned. " + + "Set excludeSystemCollections=true to exclude collections owned by the system user.", ) fun getCollections( @RequestParam(required = false) userId: Long?, @RequestParam(required = false) organism: String?, @RequestParam(required = false, defaultValue = "false") includeVariants: Boolean, + @RequestParam(required = false, defaultValue = "false") excludeSystemCollections: Boolean, ): List = collectionModel.getCollections( userId = userId, organism = organism, includeVariants = includeVariants, + excludeSystemCollections = excludeSystemCollections, ) @GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 958dc06dd..7b645d8d7 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -11,12 +11,14 @@ import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.genspectrum.dashboardsbackend.controller.ForbiddenException import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.genspectrum.dashboardsbackend.model.user.UserEntity +import org.genspectrum.dashboardsbackend.model.user.UserModel import org.genspectrum.dashboardsbackend.util.now import org.jetbrains.exposed.v1.core.JoinType import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.count import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.core.notInList import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.select @@ -27,8 +29,13 @@ import kotlin.time.Instant @Service @Transactional -class CollectionModel(private val dashboardsConfig: DashboardsConfig) { - fun getCollections(userId: Long?, organism: String?, includeVariants: Boolean = false): List { +class CollectionModel(private val dashboardsConfig: DashboardsConfig, private val userModel: UserModel) { + fun getCollections( + userId: Long?, + organism: String?, + includeVariants: Boolean = false, + excludeSystemCollections: Boolean = false, + ): List { if (userId != null) { UserEntity.findById(userId) ?: throw NotFoundException("User $userId not found") } @@ -44,6 +51,12 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { if (organism != null) { collectionConditions = collectionConditions and (CollectionTable.organism eq organism) } + if (excludeSystemCollections) { + val systemUserId = userModel.getSystemUserId() + if (systemUserId != null) { + collectionConditions = collectionConditions and (CollectionTable.ownedBy neq systemUserId) + } + } val join = CollectionTable.join(VariantTable, JoinType.LEFT) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/user/UserModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/user/UserModel.kt index d372430b4..07301699a 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/user/UserModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/user/UserModel.kt @@ -3,6 +3,7 @@ package org.genspectrum.dashboardsbackend.model.user import org.genspectrum.dashboardsbackend.api.PublicUser import org.genspectrum.dashboardsbackend.api.User import org.genspectrum.dashboardsbackend.api.UserSyncRequest +import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.genspectrum.dashboardsbackend.util.now import org.springframework.stereotype.Service @@ -10,7 +11,19 @@ import org.springframework.transaction.annotation.Transactional @Service @Transactional -class UserModel { +class UserModel(private val dashboardsConfig: DashboardsConfig) { + // Not `lazy {}` — needs @Transactional to run inside a DB transaction. + @Volatile + private var systemUserId: Long? = null + + fun getSystemUserId(): Long? { + systemUserId?.let { return it } + val githubId = dashboardsConfig.systemUser?.githubId ?: return null + val id = UserEntity.findByGithubId(githubId)?.id?.value ?: return null + systemUserId = id + return id + } + fun syncUser(request: UserSyncRequest): User { val now = now() val existing = UserEntity.findByGithubId(request.githubId) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index 067123e73..7e0115666 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -31,12 +31,14 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: userId: Long? = null, organism: String? = null, includeVariants: Boolean = false, + excludeSystemCollections: Boolean = false, ): ResultActions { val params = buildString { val queryParams = mutableListOf() if (userId != null) queryParams.add("userId=$userId") if (organism != null) queryParams.add("organism=$organism") if (includeVariants) queryParams.add("includeVariants=true") + if (excludeSystemCollections) queryParams.add("excludeSystemCollections=true") if (queryParams.isNotEmpty()) { append("?") append(queryParams.joinToString("&")) @@ -49,8 +51,9 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: userId: Long? = null, organism: String? = null, includeVariants: Boolean = false, + excludeSystemCollections: Boolean = false, ): List = deserializeJsonResponse( - getCollectionsRaw(userId, organism, includeVariants) + getCollectionsRaw(userId, organism, includeVariants, excludeSystemCollections) .andExpect(status().isOk), ) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsExcludeSystemCollectionsTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsExcludeSystemCollectionsTest.kt new file mode 100644 index 000000000..e00193bef --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsExcludeSystemCollectionsTest.kt @@ -0,0 +1,63 @@ +package org.genspectrum.dashboardsbackend.controller + +import org.genspectrum.dashboardsbackend.KnownTestOrganisms +import org.genspectrum.dashboardsbackend.dummyCollectionRequest +import org.genspectrum.dashboardsbackend.model.user.UserModel +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.not +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.TestPropertySource + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource( + properties = [ + "dashboards.system-user.github-id=test-system-user-exclude-test", + "dashboards.system-user.name=Test Bot", + ], +) +@Import(CollectionsClient::class, UsersClient::class) +class CollectionsExcludeSystemCollectionsTest( + @param:Autowired private val collectionsClient: CollectionsClient, + @param:Autowired private val usersClient: UsersClient, + @param:Autowired private val userModel: UserModel, +) { + @Test + fun `GIVEN system and regular user collections WHEN excludeSystemCollections=true THEN only regular returned`() { + val systemUserId = requireNotNull(userModel.getSystemUserId()) + val regularUserId = usersClient.createUser() + + val systemCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "System Collection", organism = KnownTestOrganisms.Covid.name), + systemUserId, + ) + val regularCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Regular Collection", organism = KnownTestOrganisms.Covid.name), + regularUserId, + ) + + val collections = collectionsClient.getCollections(includeVariants = true, excludeSystemCollections = true) + + assertThat(collections, hasItem(regularCollection)) + assertThat(collections, not(hasItem(systemCollection))) + } + + @Test + fun `GIVEN system user collection WHEN not excluding system collections THEN system collection is included`() { + val systemUserId = requireNotNull(userModel.getSystemUserId()) + + val systemCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "System Collection Visible", organism = KnownTestOrganisms.Covid.name), + systemUserId, + ) + + val collections = collectionsClient.getCollections(includeVariants = true, excludeSystemCollections = false) + + assertThat(collections, hasItem(systemCollection)) + } +} diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsGetTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsGetTest.kt index 2b9818448..0ecf366ef 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsGetTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsGetTest.kt @@ -300,6 +300,20 @@ class CollectionsGetTest( .andExpect(jsonPath("$[0].variants").doesNotExist()) } + @Test + fun `GIVEN no system user configured WHEN getting with excludeSystemCollections THEN returns all collections`() { + val userId = usersClient.createUser() + val collection = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "No System User"), userId) + + val collections = collectionsClient.getCollections( + userId = userId, + includeVariants = true, + excludeSystemCollections = true, + ) + + assertThat(collections, hasItem(collection)) + } + @Test fun `WHEN getting collections with includeVariants=true THEN variantCount and variants are both present`() { val userId = usersClient.createUser() diff --git a/collection-seeding/.dockerignore b/collection-seeding/.dockerignore new file mode 100644 index 000000000..2688cbdae --- /dev/null +++ b/collection-seeding/.dockerignore @@ -0,0 +1,7 @@ +* +!pixi.toml +!pixi.lock +!seed.py +!api.py +!models.py +!sources/ diff --git a/collection-seeding/Dockerfile b/collection-seeding/Dockerfile new file mode 100644 index 000000000..da7b06e63 --- /dev/null +++ b/collection-seeding/Dockerfile @@ -0,0 +1,13 @@ +# 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 . . +CMD ["python", "seed.py"] diff --git a/collection-seeding/README.md b/collection-seeding/README.md new file mode 100644 index 000000000..3ed909a5f --- /dev/null +++ b/collection-seeding/README.md @@ -0,0 +1,59 @@ +# 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 +- **covid-pango-lineages-sample** — same as above but limited to 10 lineages, for quick testing + +The script is idempotent — re-running it will create new collections or update existing ones (matched by name). If a collection's name changes in the source, the old entry is orphaned and a new one is created. + +Use `--repeat-interval-hours N` (or `$REPEAT_INTERVAL_HOURS`) to run on a loop — re-seeds every N hours. Without it, the script runs once and exits. + +## 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 +pixi run seed-resistance # resistance mutations only +pixi run seed-lineages # pango lineages only +pixi run seed-lineages-sample # first 10 pango lineages (quick test) +``` + +To target a different backend: + +```bash +pixi run seed --url http://localhost:4321 +``` + +Run `pixi run seed --help` for all options, including `--source`, `--list`, `--repeat-interval-hours`, and `--url`. + +## Adding a new source + +1. Create `sources/your_source.py` and implement the `Source` ABC: + ```python + from sources import Source + from models import Collection + + class YourSource(Source): + name = "your-source-name" # used with --source flag + + def get_collections(self) -> list[Collection]: + ... + ``` +2. Register it in `sources/registry.py` by adding it to `ALL_SOURCES`. diff --git a/collection-seeding/api.py b/collection-seeding/api.py new file mode 100644 index 000000000..bce08cdb3 --- /dev/null +++ b/collection-seeding/api.py @@ -0,0 +1,83 @@ +"""Shared backend API client for collection seeders.""" + +import time + +import requests + +from models import Collection, ExistingCollection + +RETRY_ATTEMPTS = 30 +RETRY_DELAY_S = 2 + + +class ApiClient: + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url.rstrip("/") + self._collections_url = f"{self.base_url}/api/collections" + self._auth_headers = {"Authorization": f"Bearer {api_key}"} + + def wait_for_api( + self, attempts: int = RETRY_ATTEMPTS, delay: float = RETRY_DELAY_S + ): + """Poll until the API is ready by checking the collections endpoint.""" + for attempt in range(1, attempts + 1): + try: + r = requests.get(self._collections_url, timeout=10) + if r.ok: + return + except requests.RequestException: + pass + print(f"Waiting for API... (attempt {attempt}/{attempts})") + time.sleep(delay) + raise RuntimeError( + f"API at {self.base_url} did not become ready after {attempts} attempts." + ) + + def fetch_existing_collections(self, organism: str) -> list[ExistingCollection]: + r = requests.get( + self._collections_url, + params={"organism": organism}, + headers=self._auth_headers, + timeout=10, + ) + if not r.ok: + raise RuntimeError(f"GET /api/collections failed: {r.status_code} {r.text}") + return r.json() + + def create_collection(self, collection: Collection) -> int: + r = requests.post( + self._collections_url, + headers=self._auth_headers, + json=collection, + timeout=10, + ) + if r.status_code != 201: + raise RuntimeError( + f"POST /api/collections failed: {r.status_code} {r.text}" + ) + return r.json()["id"] + + def delete_collection(self, collection_id: int) -> None: + r = requests.delete( + f"{self._collections_url}/{collection_id}", + headers=self._auth_headers, + timeout=10, + ) + if not r.ok: + raise RuntimeError( + f"DELETE /api/collections/{collection_id} failed: {r.status_code} {r.text}" + ) + + def update_collection(self, collection_id: int, collection: Collection) -> None: + # CollectionUpdate has no organism field; sending it causes a 400 (fail-on-unknown-properties=true) + body = {k: v for k, v in collection.items() if k != "organism"} + r = requests.put( + f"{self._collections_url}/{collection_id}", + headers=self._auth_headers, + json=body, + timeout=10, + ) + if not r.ok: + raise RuntimeError( + f"PUT /api/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..e0e636282 --- /dev/null +++ b/collection-seeding/models.py @@ -0,0 +1,29 @@ +"""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 + description: str | None diff --git a/collection-seeding/pixi.lock b/collection-seeding/pixi.lock new file mode 100644 index 000000000..37e2e83f2 --- /dev/null +++ b/collection-seeding/pixi.lock @@ -0,0 +1,1159 @@ +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/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.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/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.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/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.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/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.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' +- pypi: https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.15.14 + sha256: 715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl + name: ruff + version: 0.15.14 + sha256: be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.15.14 + sha256: 48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: ruff + version: 0.15.14 + sha256: ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d + requires_python: '>=3.7' +- 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..f495ccb5b --- /dev/null +++ b/collection-seeding/pixi.toml @@ -0,0 +1,30 @@ +[workspace] +name = "collection-seeding" +version = "0.1.0" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-arm64", "osx-64", "linux-aarch64"] + +[dependencies] +python = "3.13.*" + +[pypi-dependencies] +requests = ">=2.33.1" + +[tasks] +seed = "python seed.py" +seed-lineages = "python seed.py --source covid-pango-lineages" +seed-lineages-sample = "python seed.py --source covid-pango-lineages-sample" +seed-resistance = "python seed.py --source covid-resistance-mutations" + +[feature.test.pypi-dependencies] +pytest = ">=9.0.3" +responses = ">=0.26.0" +ruff = ">=0.9" + +[feature.test.tasks] +test = "pytest" +lint = "ruff check ." +format = "ruff format ." + +[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/collection-seeding/seed.py b/collection-seeding/seed.py new file mode 100644 index 000000000..c52140a55 --- /dev/null +++ b/collection-seeding/seed.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Seeds example collections into the backend from one or more data sources. + +Idempotent: upserts collections by name — creates new ones, updates existing ones. + +Run with --help for usage. +""" + +import argparse +import os +import sys +import time + +from api import ApiClient +from models import Collection +from sources import Source +from sources.registry import ALL_SOURCES + + +def main(): + parser = make_parser() + args = parser.parse_args() + + source_map = {cls.name: cls() for cls in ALL_SOURCES} + + if args.list: + for name in source_map: + print(name) + return + + if args.source and args.source not in source_map: + print( + f"Unknown source '{args.source}'. Use --list to see available sources.", + file=sys.stderr, + ) + sys.exit(1) + + if not args.api_key: + print("Error: --api-key is required (or set $API_KEY).", file=sys.stderr) + sys.exit(1) + + client = ApiClient(args.url, args.api_key) + print(f"Seeding collections against {args.url} ...") + + active = ( + [source_map[args.source]] + if args.source + else [s for s in source_map.values() if s.include_in_default_run] + ) + + try: + if args.wait: + client.wait_for_api() + + while True: + total_created = 0 + total_updated = 0 + total_deleted = 0 + for source in active: + c, u, d = seed_source(client, source) + total_created += c + total_updated += u + total_deleted += d + if len(active) > 1: + print( + f"\nTotal — created: {total_created}, updated: {total_updated}, deleted: {total_deleted}." + ) + if not args.repeat_interval_hours: + break + print(f"\nSleeping for {args.repeat_interval_hours}h ...") + time.sleep(args.repeat_interval_hours * 3600) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def seed_source(client: ApiClient, source: Source) -> tuple[int, int, int]: + """Upsert collections for one source. Returns (created, updated, deleted) counts. + Matching is by name — if a collection's name changes in the source, the old entry is orphaned and a new one is created.""" + collections = source.get_collections() + print(f"\n[{source.name}]") + + existing = client.fetch_existing_collections(source.organism) + existing_by_name = { + c["name"]: c for c in existing if source.owned_tag in (c["description"] or "") + } + + created = 0 + updated = 0 + for collection in collections: + c, u = _upsert_collection(client, collection, existing_by_name) + existing_by_name.pop(collection["name"], None) + created += c + updated += u + + orphan_ids = [e["id"] for e in existing_by_name.values()] + deleted = _delete_collections(client, orphan_ids) + + print(f" Created: {created}, updated: {updated}, deleted: {deleted}.") + return created, updated, deleted + + +def _upsert_collection( + client: ApiClient, + collection: Collection, + existing_by_name: dict, +) -> tuple[int, int]: + 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']}") + return 0, 1 + else: + col_id = client.create_collection(collection) + print(f" CREATE id={col_id} {collection['name']}") + return 1, 0 + + +def _delete_collections(client: ApiClient, collection_ids: list[int]) -> int: + for col_id in collection_ids: + client.delete_collection(col_id) + print(f" DELETE id={col_id}") + return len(collection_ids) + + +def make_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-u", + "--url", + default=os.environ.get("API_URL", "http://localhost:4321"), + help="Website base URL serving /api/* (default: $API_URL or http://localhost:4321)", + ) + parser.add_argument( + "-k", + "--api-key", + default=os.environ.get("API_KEY"), + help="API key for authentication (default: $API_KEY)", + ) + parser.add_argument( + "--wait", + action="store_true", + default=not sys.stdout.isatty(), + help="Retry until API is ready (auto-enabled when no TTY)", + ) + parser.add_argument( + "--source", + metavar="NAME", + help="Only run this source (default: all). Use --list to see available sources.", + ) + parser.add_argument( + "--list", + action="store_true", + help="List available sources and exit", + ) + parser.add_argument( + "--repeat-interval-hours", + type=float, + default=float(os.environ["REPEAT_INTERVAL_HOURS"]) + if os.environ.get("REPEAT_INTERVAL_HOURS") + else None, + metavar="HOURS", + help="Re-seed every N hours instead of exiting (default: $REPEAT_INTERVAL_HOURS or run once)", + ) + return parser + + +if __name__ == "__main__": + main() diff --git a/collection-seeding/sources/__init__.py b/collection-seeding/sources/__init__.py new file mode 100644 index 000000000..5f898d4a5 --- /dev/null +++ b/collection-seeding/sources/__init__.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod + +from models import Collection + + +class Source(ABC): + """A data source that produces collections to be seeded into the backend. + + Implement this to add a new source: set a unique `name` (used as the --source flag value), + an `organism`, an `owned_tag` (appended to each description and used to identify orphaned + collections for deletion), and implement `get_collections` to return the collections to upsert. + Then register it in sources/registry.py. + + Set `include_in_default_run = False` for sources that should only be used via --source + (e.g. demo/sample sources that overlap with a full source). + """ + + include_in_default_run: bool = True + + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def organism(self) -> str: ... + + @property + @abstractmethod + def owned_tag(self) -> str: ... + + @abstractmethod + def get_collections(self) -> list[Collection]: ... diff --git a/collection-seeding/sources/pango_lineages.py b/collection-seeding/sources/pango_lineages.py new file mode 100644 index 000000000..048d89b64 --- /dev/null +++ b/collection-seeding/sources/pango_lineages.py @@ -0,0 +1,92 @@ +import requests + +from models import Collection, Variant +from sources import Source + +DATA_URL = ( + "https://raw.githubusercontent.com/corneliusroemer/pango-sequences" + "/refs/heads/main/data/pango-consensus-sequences_summary.json" +) + + +class PangoLineagesSource(Source): + """Source: Pango lineage definitions from corneliusroemer/pango-sequences. + + Creates one collection per lineage, with nucleotide substitutions as variants. + """ + + name = "covid-pango-lineages" + organism = "covid" + owned_tag = "#pango-lineage" + + def __init__(self, limit: int | None = None): + self._limit = limit + + def get_collections(self) -> 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 self._limit is not None: + entries = entries[: self._limit] + print(f" Loaded {len(entries)} lineage(s).") + return [self._build_collection(e) for e in entries] + + def _build_collection(self, 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" + + nuc_subs = [s for s in entry.get("nucSubstitutions", []) if s] + aa_subs = [s for s in entry.get("aaSubstitutions", []) if s] + nuc_subs_new = [s for s in entry.get("nucSubstitutionsNew", []) if s] + aa_subs_new = [s for s in entry.get("aaSubstitutionsNew", []) if s] + + variants: list[Variant] = [ + { + "type": "filterObject", + "name": "Nucleotide substitutions", + "filterObject": {"nucleotideMutations": nuc_subs}, + }, + { + "type": "filterObject", + "name": "Amino acid substitutions", + "filterObject": {"aminoAcidMutations": aa_subs}, + }, + { + "type": "filterObject", + "name": "New nucleotide substitutions", + "filterObject": {"nucleotideMutations": nuc_subs_new}, + }, + { + "type": "filterObject", + "name": "New amino acid substitutions", + "filterObject": {"aminoAcidMutations": aa_subs_new}, + }, + ] + + description = ( + f"Pango lineage {lineage}. " + f"Parent: {parent}. " + f"Nextstrain clade: {clade}. " + f"Designated: {date}. " + f"{self.owned_tag}" + ) + + return { + "name": lineage, + "organism": "covid", + "description": description, + "variants": variants, + } + + +class PangoLineagesSampleSource(PangoLineagesSource): + """Same as PangoLineagesSource but limited to the first 10 lineages, for quick testing.""" + + name = "covid-pango-lineages-sample" + include_in_default_run = False + + def __init__(self): + super().__init__(limit=10) diff --git a/collection-seeding/sources/registry.py b/collection-seeding/sources/registry.py new file mode 100644 index 000000000..88d704005 --- /dev/null +++ b/collection-seeding/sources/registry.py @@ -0,0 +1,15 @@ +"""Registry of all available seeding sources. + +To add a new source, import it here and add it to ALL_SOURCES. This is the only +place that needs to change — seed.py discovers sources exclusively through this list. +""" + +from sources.pango_lineages import PangoLineagesSource, PangoLineagesSampleSource +from sources.resistance_mutations import ResistanceMutationsSource +from sources import Source + +ALL_SOURCES: list[type[Source]] = [ + ResistanceMutationsSource, + PangoLineagesSource, + PangoLineagesSampleSource, +] diff --git a/collection-seeding/sources/resistance_mutations.py b/collection-seeding/sources/resistance_mutations.py new file mode 100644 index 000000000..30ca66501 --- /dev/null +++ b/collection-seeding/sources/resistance_mutations.py @@ -0,0 +1,335 @@ +from models import Collection, Variant +from sources import Source + + +class ResistanceMutationsSource(Source): + """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. + """ + + name = "covid-resistance-mutations" + organism = "covid" + owned_tag = "#resistance-mutation" + + def get_collections(self) -> 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 " + f"(last updated 21 August 2024). {self.owned_tag}" + ), + "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 " + f"(last updated 21 August 2024). {self.owned_tag}" + ), + "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 " + f"(last updated 21 August 2024). {self.owned_tag}" + ), + "variants": _build_variants(SPIKE_MUTATIONS, "Spike", 0), + }, + ] + + +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 + ] + + +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", +] 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..05e9bc9a7 --- /dev/null +++ b/collection-seeding/tests/mock_source.py @@ -0,0 +1,14 @@ +from models import Collection +from sources import Source + + +class MockSource(Source): + name = "mock-source" + organism = "covid" + owned_tag = "#mock-tag" + + def __init__(self, collections: list[Collection]): + self._collections = collections + + def get_collections(self) -> list[Collection]: + return self._collections diff --git a/collection-seeding/tests/test_pango_lineages.py b/collection-seeding/tests/test_pango_lineages.py new file mode 100644 index 000000000..057301e30 --- /dev/null +++ b/collection-seeding/tests/test_pango_lineages.py @@ -0,0 +1,152 @@ +import responses as rsps_lib + +from sources.pango_lineages import PangoLineagesSource, DATA_URL + +SAMPLE_DATA = { + "BA.2": { + "lineage": "BA.2", + "unaliased": "B.1.1.529.2", + "parent": "BA", + "nextstrainClade": "22C", + "nucSubstitutions": ["C241T", "A23403G", ""], + "aaSubstitutions": ["S:N501Y", ""], + "nucSubstitutionsNew": ["A23403G"], + "aaSubstitutionsNew": ["S:N501Y"], + "designationDate": "2022-01-20", + }, + "XBB": { + "lineage": "XBB", + "unaliased": "XBB", + "parent": "", + "nextstrainClade": "", + "nucSubstitutions": [""], + "aaSubstitutions": [""], + "nucSubstitutionsNew": [""], + "aaSubstitutionsNew": [""], + "designationDate": "", + }, + "BA.5": { + "lineage": "BA.5", + "unaliased": "B.1.1.529.5", + "parent": "BA", + "nextstrainClade": "22B", + "nucSubstitutions": ["C241T", "T19955C"], + "aaSubstitutions": ["S:L452R"], + "nucSubstitutionsNew": ["T19955C"], + "aaSubstitutionsNew": [], + "designationDate": "2022-05-06", + }, +} + + +def test_name(): + assert PangoLineagesSource.name == "covid-pango-lineages" + + +# --- _build_collection --- + + +def test_build_collection_basic(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + assert col["name"] == "BA.2" + assert col["organism"] == "covid" + + +def test_build_collection_description_format(): + col = PangoLineagesSource()._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_missing_fields_use_defaults(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) + assert "—" in col["description"] # parent and clade fallback + assert "unknown" in col["description"] # date fallback + + +def test_build_collection_always_four_variants(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + assert len(col["variants"]) == 4 + + +def test_build_collection_variant_names(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + names = [v["name"] for v in col["variants"]] + assert names == [ + "Nucleotide substitutions", + "Amino acid substitutions", + "New nucleotide substitutions", + "New amino acid substitutions", + ] + + +def test_build_collection_variant_filter_keys(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + variants = col["variants"] + assert "nucleotideMutations" in variants[0]["filterObject"] + assert "aminoAcidMutations" in variants[1]["filterObject"] + assert "nucleotideMutations" in variants[2]["filterObject"] + assert "aminoAcidMutations" in variants[3]["filterObject"] + + +def test_build_collection_variant_contents(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + variants = col["variants"] + assert variants[0]["filterObject"]["nucleotideMutations"] == ["C241T", "A23403G"] + assert variants[1]["filterObject"]["aminoAcidMutations"] == ["S:N501Y"] + assert variants[2]["filterObject"]["nucleotideMutations"] == ["A23403G"] + assert variants[3]["filterObject"]["aminoAcidMutations"] == ["S:N501Y"] + + +def test_build_collection_filters_blank_subs(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + # nucSubstitutions has ["C241T", "A23403G", ""] — blank should be dropped + nuc = col["variants"][0]["filterObject"]["nucleotideMutations"] + assert "" not in nuc + assert len(nuc) == 2 + + +def test_build_collection_empty_lists_when_all_blanks(): + col = PangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) + assert len(col["variants"]) == 4 + for v in col["variants"]: + lists = list(v["filterObject"].values()) + assert lists == [[]] + + +# --- 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) + PangoLineagesSource().get_collections() + assert len(rsps_lib.calls) == 1 + assert rsps_lib.calls[0].request.url == DATA_URL + + +@rsps_lib.activate +def test_get_collections_includes_all_lineages(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + cols = PangoLineagesSource().get_collections() + # All lineages included regardless of empty subs + names = [c["name"] for c in cols] + assert "BA.2" in names + assert "XBB" in names + assert "BA.5" 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 = PangoLineagesSource(limit=1).get_collections() + assert len(cols) <= 1 + + +@rsps_lib.activate +def test_get_collections_no_limit_returns_all(): + rsps_lib.add(rsps_lib.GET, DATA_URL, json=SAMPLE_DATA, status=200) + cols = PangoLineagesSource().get_collections() + assert len(cols) == 3 diff --git a/collection-seeding/tests/test_resistance_mutations.py b/collection-seeding/tests/test_resistance_mutations.py new file mode 100644 index 000000000..c708f7673 --- /dev/null +++ b/collection-seeding/tests/test_resistance_mutations.py @@ -0,0 +1,49 @@ +from sources.resistance_mutations import ResistanceMutationsSource, _mature_name + + +def test_name(): + assert ResistanceMutationsSource.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 = ResistanceMutationsSource().get_collections() + assert len(cols) == 3 + + +def test_get_collections_all_covid(): + for col in ResistanceMutationsSource().get_collections(): + assert col["organism"] == "covid" + + +def test_get_collections_variant_structure(): + for col in ResistanceMutationsSource().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 diff --git a/collection-seeding/tests/test_seed.py b/collection-seeding/tests/test_seed.py new file mode 100644 index 000000000..9a474dc73 --- /dev/null +++ b/collection-seeding/tests/test_seed.py @@ -0,0 +1,149 @@ +from unittest.mock import MagicMock + +from seed import seed_source +from tests.mock_source import MockSource + +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 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, deleted = seed_source(client, MockSource(COLLECTIONS)) + assert created == len(COLLECTIONS) + assert updated == 0 + assert deleted == 0 + assert client.create_collection.call_count == len(COLLECTIONS) + client.update_collection.assert_not_called() + + +def existing_entry(id: int, name: str) -> dict: + return { + "id": id, + "name": name, + "description": f"A collection. {MockSource.owned_tag}", + } + + +def test_all_existing_updates_all(): + existing = [existing_entry(i + 1, c["name"]) for i, c in enumerate(COLLECTIONS)] + client = make_client(existing=existing) + created, updated, deleted = seed_source(client, MockSource(COLLECTIONS)) + assert created == 0 + assert updated == len(COLLECTIONS) + assert deleted == 0 + assert client.update_collection.call_count == len(COLLECTIONS) + client.create_collection.assert_not_called() + + +def test_mixed_creates_and_updates(): + existing = [existing_entry(10, COLLECTIONS[0]["name"])] + client = make_client(existing=existing) + created, updated, deleted = seed_source(client, MockSource(COLLECTIONS)) + assert created == len(COLLECTIONS) - 1 + assert updated == 1 + + +def test_update_uses_correct_id(): + existing = [existing_entry(42, COLLECTIONS[0]["name"])] + client = make_client(existing=existing) + seed_source(client, MockSource([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, MockSource([COLLECTIONS[0]])) + client.create_collection.assert_called_once_with(COLLECTIONS[0]) + + +def test_returns_zero_counts_for_empty_collections(): + client = make_client(existing=[]) + created, updated, deleted = seed_source(client, MockSource([])) + assert created == 0 + assert updated == 0 + assert deleted == 0 + + +# --- seed_source: orphan deletion --- + +TAG = "#test-tag" + + +class TaggedMockSource(MockSource): + owned_tag = TAG + + +def tagged(name: str, description: str = "") -> dict: + return { + "name": name, + "organism": "covid", + "description": description or f"A collection. {TAG}", + "variants": [], + } + + +def test_orphan_with_tag_is_deleted(): + existing = [ + {"id": 5, "name": "OldLineage", "description": f"Old. {TAG}"}, + {"id": 6, "name": "CurrentLineage", "description": f"Current. {TAG}"}, + ] + client = make_client(existing=existing) + created, updated, deleted = seed_source( + client, TaggedMockSource([tagged("CurrentLineage")]) + ) + assert deleted == 1 + client.delete_collection.assert_called_once_with(5) + + +def test_orphan_without_tag_is_not_deleted(): + existing = [{"id": 5, "name": "ManualCollection", "description": "No tag here."}] + client = make_client(existing=existing) + created, updated, deleted = seed_source(client, TaggedMockSource([])) + assert deleted == 0 + client.delete_collection.assert_not_called() + + +def test_no_deletion_when_owned_tag_is_none(): + existing = [{"id": 5, "name": "OldLineage", "description": f"Old. {TAG}"}] + client = make_client(existing=existing) + created, updated, deleted = seed_source(client, MockSource([])) + assert deleted == 0 + client.delete_collection.assert_not_called() + + +def test_current_collections_are_not_deleted(): + col = tagged("ExistingLineage") + existing = [{"id": 5, "name": "ExistingLineage", "description": col["description"]}] + client = make_client(existing=existing) + created, updated, deleted = seed_source(client, TaggedMockSource([col])) + assert deleted == 0 + client.delete_collection.assert_not_called() diff --git a/docker-compose.yml b/docker-compose.yml index a5c499de7..16e0848a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - "127.0.0.1:9021:8080" depends_on: - database + environment: + DASHBOARDS_SYSTEM_USER_API_KEY: dummy-system-user-api-key-insecure command: - --spring.datasource.url=jdbc:postgresql://database:5432/dashboards-backend-db - --spring.datasource.username=postgres @@ -39,11 +41,12 @@ services: example-data-seeder: image: ghcr.io/genspectrum/dashboards/example-data-seeder:${SEEDER_TAG} depends_on: - - backend + - website environment: - BACKEND_URL: http://backend:8080 - SEED_USER_ID: example-data-seeder - restart: "no" + API_URL: http://website:4321 + API_KEY: dummy-system-user-api-key-insecure + REPEAT_INTERVAL_HOURS: 8 + restart: always volumes: database-data: diff --git a/docs/arc42/09-architecture-decisions.md b/docs/arc42/09-architecture-decisions.md index 6f0853a9c..32e4c44f1 100644 --- a/docs/arc42/09-architecture-decisions.md +++ b/docs/arc42/09-architecture-decisions.md @@ -29,3 +29,25 @@ We would have needed to host it on one of our servers and configure it properly. Although is it relatively easy to get a Keycloak Docker image running, it is still difficult to get the configuration right. None of the team members was an expert in Keycloak and misconfigurations can lead to security issues. + +## Auto-creation of collections + +### Context + +As GenSpectrum, we want to provide a few collections that we create based on other online resources. +Ideally, others would maintain their own collections, but for now we want to do it ourselves. +For example, we create collections for mutations that are relevant for vaccine resistance, based +on online lists. Or we create collections based on canonical lineage definitions. + +Since we want others to also be able to easily generate collections, the code should also serve +as a kind of reference implementation on how one would generate collections. Therefore, we want the +code to be completely independent of the rest of the codebase, and also understandable to +bioinformatics researchers. + +### Decision + +We decided to write the code in Python. +The alternative would have been to use JavaScript, since we already have that. But since we want +researchers to reuse or copy the code, Python is better suited. +Kotlin was not considered — it is heavier than both Python and JavaScript and even less familiar +to the target audience. 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. diff --git a/example-data/seed.mjs b/example-data/seed.mjs deleted file mode 100644 index 3ecdb4dc9..000000000 --- a/example-data/seed.mjs +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env node -// Seeds example collections into the backend. -// Idempotent: skips any collection whose name already exists for the seed user. -// -// Run with --help for usage. - -import { parseArgs } from 'node:util'; - -const HELP = `\ -Usage: node seed.mjs [options] - -Options: - -u, --url Backend base URL (default: $BACKEND_URL or http://localhost:8080) - --user-id User ID (default: $SEED_USER_ID or example-data-seeder) - --wait Retry until backend is ready (auto-enabled when no TTY) - -h, --help Show this help - -Examples: - # Local backend running on :8080 - node seed.mjs - - # Local backend on a different port - node seed.mjs --url http://localhost:9021 -`; - -let parsedArgs; -try { - parsedArgs = parseArgs({ - options: { - url: { type: 'string', short: 'u' }, - 'user-id': { type: 'string' }, - wait: { type: 'boolean' }, - help: { type: 'boolean', short: 'h' }, - }, - }); -} catch (err) { - console.error(`Error: ${err.message}\n`); - console.error(HELP); - process.exit(1); -} - -const { values } = parsedArgs; - -if (values.help) { - console.log(HELP); - process.exit(0); -} - -const BACKEND_URL = values.url ?? process.env.BACKEND_URL ?? 'http://localhost:8080'; -const SEED_USER_ID = values['user-id'] ?? process.env.SEED_USER_ID ?? 'example-data-seeder'; -// Auto-enable wait when there's no TTY (e.g. running inside Docker). -const WAIT = values.wait ?? !process.stdout.isTTY; - -const RETRY_ATTEMPTS = 30; -const RETRY_DELAY_MS = 2000; -const COLLECTIONS_BASE = `${BACKEND_URL}/collections`; - -// Converts a genomic mutation code to a mature protein name with the given offset. -// e.g. matureName("ORF1a:T3284I", "3CLpro", -3263) => "3CLpro:T21I" -function matureName(mutation, setName, offset) { - const mutationPart = mutation.slice(mutation.indexOf(':') + 1); - const originalBase = mutationPart[0]; - const newBase = mutationPart[mutationPart.length - 1]; - const position = parseInt(mutationPart.match(/\d+/)[0], 10); - return `${setName}:${originalBase}${position + offset}${newBase}`; -} - -function buildVariants(mutations, setName, offset) { - return mutations.map((mutation) => ({ - type: 'filterObject', - name: matureName(mutation, setName, offset), - filterObject: { aminoAcidMutations: [mutation] }, - })); -} - -// --- Collection definitions --- - -const 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', -]; - -const 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', -]; - -const 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', -]; - -const COLLECTIONS = [ - { - 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: buildVariants(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: buildVariants(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: buildVariants(SPIKE_MUTATIONS, 'Spike', 0), - }, -]; - -// --- API helpers --- - -async function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForBackend() { - for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) { - try { - const response = await fetch( - `${BACKEND_URL}/collections?userId=${SEED_USER_ID}&organism=covid`, - ); - if (response.ok || response.status === 404) return; - } catch { - // backend not ready yet - } - console.log(`Waiting for backend... (attempt ${attempt}/${RETRY_ATTEMPTS})`); - await sleep(RETRY_DELAY_MS); - } - console.error(`Backend at ${BACKEND_URL} did not become ready after ${RETRY_ATTEMPTS} attempts.`); - process.exit(1); -} - -async function fetchExistingCollections(organism) { - const url = `${COLLECTIONS_BASE}?userId=${encodeURIComponent(SEED_USER_ID)}&organism=${encodeURIComponent(organism)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`GET /collections failed: ${response.status} ${await response.text()}`); - } - return response.json(); -} - -async function createCollection(collection) { - const url = `${COLLECTIONS_BASE}?userId=${encodeURIComponent(SEED_USER_ID)}`; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(collection), - }); - if (response.status !== 201) { - throw new Error(`POST /collections failed: ${response.status} ${await response.text()}`); - } - const created = await response.json(); - return created.id; -} - -// --- Main --- - -async function main() { - console.log(`Seeding example data against ${BACKEND_URL} as user '${SEED_USER_ID}'...`); - - if (WAIT) { - await waitForBackend(); - } - - // Group collections by organism to minimise GET requests - const byOrganism = {}; - for (const collection of COLLECTIONS) { - (byOrganism[collection.organism] ??= []).push(collection); - } - - let created = 0; - let skipped = 0; - - for (const [organism, collections] of Object.entries(byOrganism)) { - const existing = await fetchExistingCollections(organism); - const existingNames = new Set(existing.map((c) => c.name)); - - for (const collection of collections) { - if (existingNames.has(collection.name)) { - console.log(` SKIP ${collection.name}`); - skipped++; - } else { - const id = await createCollection(collection); - console.log(` OK id=${id} ${collection.name}`); - created++; - } - } - } - - console.log(`\nDone. Created: ${created}, skipped (already exist): ${skipped}.`); -} - -try { - await main(); -} catch (err) { - console.error(err); - process.exit(1); -} diff --git a/website/routeMocker.ts b/website/routeMocker.ts index 1058170fb..9551e1d75 100644 --- a/website/routeMocker.ts +++ b/website/routeMocker.ts @@ -30,11 +30,27 @@ export class AstroApiRouteMocker { this.workerOrServer.use(http.post(`${ASTRO_SERVER_URL}/log`, () => Response.json({}))); } - mockGetCollectionSummaries(response: CollectionSummary[], organism?: string, statusCode = 200) { + mockGetCollectionSummaries( + response: CollectionSummary[], + organism?: string, + statusCode = 200, + excludeSystemCollections?: boolean, + ) { this.workerOrServer.use( http.get( `${ASTRO_SERVER_URL}/collections`, - resolver([{ statusCode, response, requestParam: organism !== undefined ? { organism } : undefined }]), + resolver([ + { + statusCode, + response, + requestParam: { + ...(organism !== undefined ? { organism } : {}), + ...(excludeSystemCollections !== undefined + ? { excludeSystemCollections: String(excludeSystemCollections) } + : {}), + }, + }, + ]), ), ); } @@ -215,15 +231,6 @@ export class BackendRouteMocker { }), ); } - - mockGetCollectionSummaries(response: CollectionSummary[], organism?: string, statusCode = 200) { - this.workerOrServer.use( - http.get( - `${DUMMY_BACKEND_URL}/collections`, - resolver([{ statusCode, response, requestParam: organism !== undefined ? { organism } : undefined }]), - ), - ); - } } function resolver(cases: MockCase[]) { diff --git a/website/src/backendApi/backendService.spec.ts b/website/src/backendApi/backendService.spec.ts index 39fea2bbe..3a3605e40 100644 --- a/website/src/backendApi/backendService.spec.ts +++ b/website/src/backendApi/backendService.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from 'vitest'; import { BackendError, BackendService, UnknownBackendError } from './backendService.ts'; import { DUMMY_BACKEND_URL } from '../../routeMocker.ts'; import { backendRouteMocker } from '../../vitest.setup.ts'; -import type { CollectionSummary } from '../types/Collection.ts'; import type { SubscriptionRequest, SubscriptionResponse, TriggerEvaluationResponse } from '../types/Subscription.ts'; describe('backendService', () => { @@ -171,27 +170,4 @@ describe('backendService', () => { new UnknownBackendError('Bad Request', 400, '/subscriptions'), ); }); - - const aCollection: CollectionSummary = { - id: 1, - name: 'Test collection', - ownedBy: 123, - organism: 'covid', - description: 'A test collection', - variantCount: 0, - }; - - test('should GET collection summaries without organism filter', async () => { - backendRouteMocker.mockGetCollectionSummaries([aCollection]); - - await expect(backendService.getCollectionSummaries()).resolves.to.deep.equal([aCollection]); - }); - - test('should GET collection summaries filtered by organism', async () => { - backendRouteMocker.mockGetCollectionSummaries([aCollection], 'covid'); - - await expect(backendService.getCollectionSummaries({ organism: 'covid' })).resolves.to.deep.equal([ - aCollection, - ]); - }); }); diff --git a/website/src/backendApi/backendService.ts b/website/src/backendApi/backendService.ts index 3b829a0c2..8019e6d9d 100644 --- a/website/src/backendApi/backendService.ts +++ b/website/src/backendApi/backendService.ts @@ -22,7 +22,7 @@ const X_REQUEST_ID_HEADER = 'x-request-id'; type EndpointParameters = { url: string; - requestParams?: Record; + requestParams?: Record; schema: ZodSchema; }; @@ -177,8 +177,7 @@ export class BackendService extends ApiService { return this.get({ url: `/users/${id}`, schema: publicUserSchema }); } - public async getCollectionSummaries({ organism }: { organism?: string } = {}) { - const requestParams = organism !== undefined ? { organism } : undefined; + public async getCollectionSummaries(requestParams: { organism?: string; excludeSystemCollections?: boolean } = {}) { return this.get({ url: '/collections', requestParams, schema: z.array(collectionSummarySchema) }); } diff --git a/website/src/components/collections/overview/CollectionsOverview.browser.spec.tsx b/website/src/components/collections/overview/CollectionsOverview.browser.spec.tsx index a36ce8a2a..b83c358f0 100644 --- a/website/src/components/collections/overview/CollectionsOverview.browser.spec.tsx +++ b/website/src/components/collections/overview/CollectionsOverview.browser.spec.tsx @@ -29,7 +29,7 @@ const mockCollections = [ describe('CollectionsOverview', () => { it('shows the headline and collections table on success', async ({ routeMockers: { astro } }) => { - astro.mockGetCollectionSummaries(mockCollections, ORGANISM); + astro.mockGetCollectionSummaries(mockCollections, ORGANISM, 200, true); const { getByText, getByRole } = render(); @@ -42,7 +42,7 @@ describe('CollectionsOverview', () => { }); it('shows the headline and empty message when there are no collections', async ({ routeMockers: { astro } }) => { - astro.mockGetCollectionSummaries([], ORGANISM); + astro.mockGetCollectionSummaries([], ORGANISM, 200, true); const { getByText } = render(); @@ -51,7 +51,7 @@ describe('CollectionsOverview', () => { }); it('shows the headline and error message when the fetch fails', async ({ routeMockers: { astro } }) => { - astro.mockGetCollectionSummaries([], ORGANISM, 500); + astro.mockGetCollectionSummaries([], ORGANISM, 500, true); astro.mockLog(); const { getByText } = render(); diff --git a/website/src/components/collections/overview/CollectionsOverview.tsx b/website/src/components/collections/overview/CollectionsOverview.tsx index 2daf07383..5f83635b1 100644 --- a/website/src/components/collections/overview/CollectionsOverview.tsx +++ b/website/src/components/collections/overview/CollectionsOverview.tsx @@ -21,7 +21,8 @@ function CollectionsOverviewInner({ organism, isLoggedIn }: { organism: Organism error, } = useQuery({ queryKey: ['collections', organism], - queryFn: () => getBackendServiceForClientside().getCollectionSummaries({ organism }), + queryFn: () => + getBackendServiceForClientside().getCollectionSummaries({ organism, excludeSystemCollections: true }), }); if (isError) { diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 59de27440..85b5be6a4 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -1,4 +1,3 @@ -import type { MeanProportionInterval } from '@genspectrum/dashboard-components/util'; import { useMemo } from 'react'; import { type FC } from 'react'; @@ -6,6 +5,7 @@ import { CollectionInfo } from './components/CollectionInfo'; import { NoDataHelperText } from './components/NoDataHelperText'; import { VariantFetchInfo } from './components/VariantFetchInfo'; import { WasapStats } from './components/WasapStats'; +import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; import type { ResistanceData } from './resistanceData'; import { useWasapPageData } from './useWasapPageData'; import type { WasapPageConfig } from './wasapPageConfig'; @@ -39,10 +39,7 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = // fetch which mutations should be analyzed const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); - let initialMeanProportionInterval: MeanProportionInterval = { min: 0.0, max: 1.0 }; - if (analysis.mode === 'manual' && analysis.mutations === undefined) { - initialMeanProportionInterval = { min: 0.05, max: 0.95 }; - } + const initialMeanProportionInterval = getInitialMeanProportionInterval(analysis); const lapisFilter = { ...(base.locationName && { locationName: base.locationName }), diff --git a/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts new file mode 100644 index 000000000..03f38a240 --- /dev/null +++ b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; +import type { WasapAnalysisFilter } from './wasapPageConfig'; + +describe('getInitialMeanProportionInterval', () => { + test('resistance mutations initially show mean proportion from 5 to 100 percent', () => { + const analysis: WasapAnalysisFilter = { + mode: 'resistance', + sequenceType: 'amino acid', + resistanceSet: 'Spike', + }; + + expect(getInitialMeanProportionInterval(analysis)).toEqual({ min: 0.05, max: 1.0 }); + }); + + test('manual mode without mutations keeps the previous 5 to 95 percent default', () => { + const analysis: WasapAnalysisFilter = { + mode: 'manual', + sequenceType: 'nucleotide', + mutations: undefined, + }; + + expect(getInitialMeanProportionInterval(analysis)).toEqual({ min: 0.05, max: 0.95 }); + }); + + test('other analysis states initially show the full mean proportion range', () => { + const analysis: WasapAnalysisFilter = { + mode: 'variant', + sequenceType: 'nucleotide', + variant: 'XFG*', + minProportion: 0.8, + minCount: 15, + minJaccard: 0.75, + timeFrame: 'all', + }; + + expect(getInitialMeanProportionInterval(analysis)).toEqual({ min: 0.0, max: 1.0 }); + }); +}); diff --git a/website/src/components/views/wasap/initialMeanProportionInterval.ts b/website/src/components/views/wasap/initialMeanProportionInterval.ts new file mode 100644 index 000000000..ec9a42773 --- /dev/null +++ b/website/src/components/views/wasap/initialMeanProportionInterval.ts @@ -0,0 +1,13 @@ +import type { MeanProportionInterval } from '@genspectrum/dashboard-components/util'; + +import type { WasapAnalysisFilter } from './wasapPageConfig'; + +export function getInitialMeanProportionInterval(analysis: WasapAnalysisFilter): MeanProportionInterval { + if (analysis.mode === 'resistance') { + return { min: 0.05, max: 1.0 }; + } + if (analysis.mode === 'manual' && analysis.mutations === undefined) { + return { min: 0.05, max: 0.95 }; + } + return { min: 0.0, max: 1.0 }; +} diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 878798e87..2c4cbfe69 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -38,6 +38,8 @@ export type WasapPageConfigBase = { browseDataUrl: string; browseDataDescription: string; + + defaultAnalysisMode?: WasapAnalysisMode; }; /** diff --git a/website/src/layouts/base/header/getPathogenMegaMenuSections.ts b/website/src/layouts/base/header/getPathogenMegaMenuSections.ts index fcad22493..70015a43c 100644 --- a/website/src/layouts/base/header/getPathogenMegaMenuSections.ts +++ b/website/src/layouts/base/header/getPathogenMegaMenuSections.ts @@ -1,5 +1,5 @@ import type { MenuIconType } from '../../../components/iconCss.ts'; -import { getOrganismConfig, isStaging } from '../../../config.ts'; +import { getOrganismConfig } from '../../../config.ts'; import { type Organism, organismConfig, paths } from '../../../types/Organism.ts'; import { Page } from '../../../types/pages.ts'; import { @@ -51,8 +51,7 @@ export function getPathogenMegaMenuSections(): PathogenMegaMenuSections { const supplementaryEntries: MegaMenuSection[] = []; - // only on staging for now, remove when enabling on prod: https://github.com/GenSpectrum/dashboards/issues/1108 - if (isStaging() && getOrganismConfig(config.organism).hasCollections) { + if (getOrganismConfig(config.organism).hasCollections) { supplementaryEntries.push({ label: 'Collections', href: Page.collectionsForOrganism(config.organism), diff --git a/website/src/pages/collections/[organism]/index.astro b/website/src/pages/collections/[organism]/index.astro index f4999df09..043eddf84 100644 --- a/website/src/pages/collections/[organism]/index.astro +++ b/website/src/pages/collections/[organism]/index.astro @@ -1,16 +1,10 @@ --- import { CollectionsOverview } from '../../../components/collections/overview/CollectionsOverview'; -import { isStaging } from '../../../config'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs'; import ContaineredPageLayout from '../../../layouts/ContaineredPage/ContaineredPageLayout.astro'; import { organismConfig, organismSchema } from '../../../types/Organism'; import { Page } from '../../../types/pages'; -// only on staging for now, remove when enabling on prod: https://github.com/GenSpectrum/dashboards/issues/1108 -if (!isStaging()) { - return Astro.redirect('/404'); -} - const { organism } = Astro.params; const parsedOrganism = organismSchema.safeParse(organism); diff --git a/website/src/pages/collections/index.astro b/website/src/pages/collections/index.astro index 031dfbabd..760f56910 100644 --- a/website/src/pages/collections/index.astro +++ b/website/src/pages/collections/index.astro @@ -1,14 +1,9 @@ --- -import { getOrganismConfig, isStaging } from '../../config'; +import { getOrganismConfig } from '../../config'; import { defaultBreadcrumbs } from '../../layouts/Breadcrumbs'; import ContaineredPageLayout from '../../layouts/ContaineredPage/ContaineredPageLayout.astro'; import { allOrganisms, organismConfig } from '../../types/Organism'; import { Page } from '../../types/pages'; - -// only on staging for now, remove when enabling on prod: https://github.com/GenSpectrum/dashboards/issues/1108 -if (!isStaging()) { - return Astro.redirect('/404'); -} --- { test('default resistance set name is valid', () => { @@ -10,4 +11,25 @@ describe.each(Object.entries(wastewaterOrganismConfigs))('wastewaterConfig %s', expect(resistanceSetNames).include(defaultSetName); } }); + + test('configured default mode must be enabled', () => { + const defaultMode = config.defaultAnalysisMode; + if (defaultMode === undefined) { + return; + } + + // Prevent configs from pointing the URL-less page state at a disabled mode. + expect(enabledAnalysisModes(config)).include(defaultMode); + }); +}); + +test('COVID wastewater opens on Spike resistance mutations by default', () => { + const covidConfig = wastewaterOrganismConfigs[wastewaterOrganisms.covid]; + + // This pins the default requested for the COVID wastewater dashboard landing state. + expect(covidConfig.defaultAnalysisMode).toBe('resistance'); + if (!covidConfig.resistanceAnalysisModeEnabled) { + throw new Error('COVID wastewater resistance analysis mode must be enabled.'); + } + expect(covidConfig.filterDefaults.resistance.resistanceSet).toBe('Spike'); }); diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 5c298ad6e..495ab0a08 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -29,6 +29,7 @@ export const wastewaterOrganismConfigs: Record { const url = handler.getDefaultPageUrl(); expect(url).toBe('/wastewater/covid'); }); + + it('uses configured default mode when URL has no analysisMode', () => { + const handlerWithDefaultMode = new WasapPageStateHandler({ + ...config, + defaultAnalysisMode: 'resistance', + }); + + // A bare URL should use the configured mode instead of the first enabled mode. + const filter = handlerWithDefaultMode.parsePageStateFromUrl(new URL('http://example.com/wastewater/covid')); + + expect(filter.analysis.mode).toBe('resistance'); + expect((filter.analysis as WasapResistanceFilter).resistanceSet).toBe('3CLpro'); + }); }); describe('base filter', () => { diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts index ecc35e731..37f6909a4 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts @@ -35,7 +35,7 @@ export class WasapPageStateHandler implements PageStateHandler { const providedMode = texts.analysisMode as WasapAnalysisMode | undefined; // config provided defaults - const defaultMode = enabledAnalysisModes(this.config)[0]; + const defaultMode = this.config.defaultAnalysisMode ?? enabledAnalysisModes(this.config)[0]; const mode = providedMode ?? defaultMode;