diff --git a/.github/workflows/docker_build_omop_db.yml b/.github/workflows/docker_build_omop_db.yml new file mode 100644 index 000000000..6a7dd908c --- /dev/null +++ b/.github/workflows/docker_build_omop_db.yml @@ -0,0 +1,146 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: Build and Push Docker Image for OMOP DB + +on: + workflow_dispatch: + # Publish only after the omop-db test suite ("Trust - OMOP DB CI") completes + # on main/develop; the job-level `if` below gates on its success. The image + # is vocab-free (FLIP#842) — nothing licensed enters the build context, which + # is what makes this CI build possible at all. + workflow_run: + workflows: ["Trust - OMOP DB CI"] + types: [completed] + branches: [main, develop] + +permissions: + contents: read + +jobs: + build-and-push: + # Only publish when the upstream test run passed. workflow_dispatch (manual) + # has no workflow_run context, so allow it through unconditionally. + # The repository guard skips publishing on forks, which cannot push to + # ghcr.io/londonaicentre and would otherwise show a spurious CI failure. + if: >- + (github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success') && + github.repository == 'londonaicentre/FLIP' + runs-on: ubuntu-latest + permissions: # override top-level read-only default to allow GHCR push + contents: read + packages: write + defaults: + run: + working-directory: ./trust/omop-db + env: + REGISTRY: ghcr.io + IMAGE_NAME: londonaicentre/omop-db + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # workflow_run.* is empty on manual workflow_dispatch runs, so fall + # back to the dispatched ref so manual builds check out correctly. + repository: ${{ github.event.workflow_run.head_repository.full_name || github.repository }} + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Determine tags + id: tags + env: + GH_REF_NAME: ${{ github.ref_name }} + GH_EVENT_NAME: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + GH_SHA: ${{ github.sha }} + GH_WR_BRANCH: ${{ github.event.workflow_run.head_branch }} + GH_WR_EVENT: ${{ github.event.workflow_run.event }} + GH_WR_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + # On a workflow_run trigger, github.sha / github.ref_name point at the + # default branch (where this workflow definition lives), NOT the commit + # that was tested and checked out. Use the triggering run's head_sha / + # head_branch so the : and : tags match the built code. + if [[ "$GH_EVENT_NAME" == "workflow_run" ]]; then + SHA="$GH_WR_SHA" + REF_NAME="$GH_WR_BRANCH" + else + SHA="$GH_SHA" + REF_NAME="$GH_REF_NAME" + fi + + TAGS="${REGISTRY}/${IMAGE_NAME}:${SHA}" + + # An empty sha would silently publish a mutable literal `sha-` tag — refuse. + [[ -n "$SHA" ]] || { echo "::error::empty commit SHA — cannot compute the sha- tag"; exit 1; } + + # Immutable short-SHA tag (FLIP#751), pushed uniformly on every publish. + # Length 7 must match the tag resolution in deploy/providers/AWS/Makefile. + TAGS="${TAGS},${REGISTRY}/${IMAGE_NAME}:sha-${SHA:0:7}" + + # Branch name sanitization + SAFE_REF_NAME=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9]/-/g') + TAGS="${TAGS},${REGISTRY}/${IMAGE_NAME}:${SAFE_REF_NAME}" + + # Branch number (if starts with number) + if [[ "$REF_NAME" =~ ^[0-9]+ ]]; then + BRANCH_NUM=$(echo "$REF_NAME" | grep -oE '^[0-9]+') + TAGS="${TAGS},${REGISTRY}/${IMAGE_NAME}:${BRANCH_NUM}" + fi + + # PR Number (if PR) + if [[ "$GH_EVENT_NAME" == "pull_request" ]]; then + PR_NUMBER=$(echo "$GH_REF" | awk -F / '{print $3}') + TAGS="${TAGS},${REGISTRY}/${IMAGE_NAME}:pr-${PR_NUMBER}" + fi + + # Determine if this is a merge/push to main or develop + BRANCH_NAME="" + if [[ "$GH_EVENT_NAME" == "workflow_run" && "$GH_WR_EVENT" == "push" ]]; then + BRANCH_NAME="$GH_WR_BRANCH" + elif [[ "$GH_EVENT_NAME" == "push" ]]; then + BRANCH_NAME="$GH_REF_NAME" + fi + + if [[ "$BRANCH_NAME" == "main" ]]; then + TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" + elif [[ "$BRANCH_NAME" == "develop" ]]; then + TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stag" + fi + echo "tags=${TAGS}" >> $GITHUB_OUTPUT + echo "Generated tags: ${TAGS}" + + - name: Log in to GitHub Container Registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | docker login $REGISTRY -u "$GH_ACTOR" --password-stdin + + - name: Build Docker image + env: + DOCKER_TAGS: ${{ steps.tags.outputs.tags }} + run: | + IFS=',' read -ra TAG_ARRAY <<< "$DOCKER_TAGS" + TAG_FLAGS=() + for tag in "${TAG_ARRAY[@]}"; do + TAG_FLAGS+=("-t" "$tag") + done + docker build "${TAG_FLAGS[@]}" . + + - name: Push Docker image + env: + DOCKER_TAGS: ${{ steps.tags.outputs.tags }} + run: | + IFS=',' read -ra TAG_ARRAY <<< "$DOCKER_TAGS" + for tag in "${TAG_ARRAY[@]}"; do + docker push "$tag" + done diff --git a/.github/workflows/secret-scanning.yml b/.github/workflows/secret-scanning.yml index 9253c0578..17df8df97 100644 --- a/.github/workflows/secret-scanning.yml +++ b/.github/workflows/secret-scanning.yml @@ -122,9 +122,14 @@ jobs: run: pip install uv==0.11.16 - name: Verify uv lockfiles (drift + exclude-newer window) + # Keep this list in step with the other two copies of it: UV_PROJECTS + # in the root Makefile and the per-project uv-lock hooks in + # .pre-commit-config.yaml. A project missing here still gets the + # commit-time hook, but loses the CI backstop that catches a + # widened exclude-newer pushed with --no-verify. run: | set -euo pipefail - for dir in . flip-api docs trust/trust-api trust/imaging-api trust/data-access-api trust/xnat/tests deploy/providers/AWS; do + for dir in . flip-api docs trust/trust-api trust/imaging-api trust/data-access-api trust/omop-db trust/xnat/tests deploy/providers/AWS; do echo "::group::uv lock --check ($dir)" uv lock --check --project "$dir" echo "::endgroup::" diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 7332f72f9..da95a7c9d 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -101,6 +101,112 @@ jobs: --set omopDb.external.host=test.example.com \ > /dev/null + # omopDb.vocabLoad.s3Bucket defaults to "" (the licensed bundle has no public + # mirror — FLIP#842/843), so every other render in this job skips the + # vocab-load Job. Without this step its ~110-line body is never rendered in + # CI and a broken .Values path there would merge green, only to fail the + # WHOLE Helm release of the first operator who configures a bucket. + - name: Render template (vocab-load enabled) + run: | + helm template trust-release deploy/providers/kubernetes/ \ + --set omopDb.vocabLoad.s3Bucket=test-bucket > /tmp/vocab.yaml + if ! grep -q "omop-vocab-load" /tmp/vocab.yaml; then + echo "::error::the vocab-load Job did not render with s3Bucket set — its body is now untested in CI" + exit 1 + fi + + # The probe stage is what keeps a routine `helm upgrade` from re-downloading + # the multi-GB bundle into a fresh emptyDir. Drop it and nothing breaks + # loudly — the Job still loads correctly, just slowly, on the critical path + # of a release hook. Scoped with --show-only so these strings have to appear + # in THIS Job and not merely somewhere in the release. + - name: Vocab-load probes before it fetches + run: | + helm template trust-release deploy/providers/kubernetes/ \ + --set omopDb.vocabLoad.s3Bucket=test-bucket \ + --show-only templates/omop-db-vocab-load-job.yaml > /tmp/vocabjob.yaml + # Every pattern is passed with -e: three of these begin with "-" and + # would otherwise be parsed as grep options rather than as patterns. + # fetch-bundle is asserted here as well as below, so that the ordering + # check cannot pass vacuously on an empty line number if it is renamed. + # load-vocab and its loader invocation are asserted because none of the + # probe/fetch strings would notice a Job that probes, fetches, and then + # never loads — which is green everywhere else and leaves empty cohorts. + # + # The marker patterns deliberately carry their whole `if` line rather + # than just the path. Asserting the bare token `-f /vocab-work/...` + # matches `! -f /vocab-work/...` too, so inverting the fetch guard — + # which makes every upgrade re-download the bundle, silently, the exact + # regression this step exists to catch — passed the earlier form. Same + # for the touch: asserted alone it survives `--check || true; touch`, + # which writes the marker unconditionally and only surfaces on the next + # fresh install, as a failed release. + for want in \ + '- name: probe-vocab' \ + '- name: fetch-bundle' \ + '- name: load-vocab' \ + 'if /flip/omop/load_core_vocab.sh --check; then' \ + '/flip/omop/load_core_vocab.sh "/vocab-work/' \ + 'touch /vocab-work/.vocab-present' \ + 'if [ -f /vocab-work/.vocab-present ]; then' + do + if ! grep -qF -e "${want}" /tmp/vocabjob.yaml; then + echo "::error::vocab-load Job no longer probes before fetching (missing: ${want})" + exit 1 + fi + done + # The probe is only worth anything if it runs BEFORE the fetch. + probe_at=$(grep -nF -m1 -e '- name: probe-vocab' /tmp/vocabjob.yaml | cut -d: -f1) + fetch_at=$(grep -nF -m1 -e '- name: fetch-bundle' /tmp/vocabjob.yaml | cut -d: -f1) + if [ "${probe_at}" -gt "${fetch_at}" ]; then + echo "::error::probe-vocab must be ordered before fetch-bundle in initContainers" + exit 1 + fi + # unzip is baked into the omop-db image precisely so this Job never + # installs anything at run time: S3 must be the only host it needs to + # reach, or a trust behind an egress allowlist cannot load a vocabulary. + # The other half of that contract — that the image really does ship + # unzip — is asserted in test_trust_omop_db.yml, which is where it can + # actually fire: THIS workflow's pull_request trigger is path-filtered + # to deploy/providers/kubernetes/**, so it does not run for a change to + # the Dockerfile it would be guarding. + if grep -qE 'microdnf|dnf |yum |apt-get |apt |apk add|pip install' /tmp/vocabjob.yaml; then + echo "::error::vocab-load Job installs packages at run time again — that needs egress to a package mirror" + exit 1 + fi + # All three containers share the work dir. Drop the mount from + # fetch-bundle and its skip test reads a path that cannot exist, so + # every upgrade silently re-downloads the bundle it just skipped. + mounts=$(grep -cF -e 'mountPath: /vocab-work' /tmp/vocabjob.yaml) + if [ "${mounts}" -ne 3 ]; then + echo "::error::expected /vocab-work mounted in all 3 containers, found ${mounts}" + exit 1 + fi + # A deleted resources value renders as `resources:` + `null`, which + # drops the ephemeral-storage bound the work dir relies on. + if grep -A1 -E '^\s*resources:' /tmp/vocabjob.yaml | grep -qE '^\s*null\s*$'; then + echo "::error::a vocabLoad resources block rendered null — the emptyDir would be bounded only by the node" + exit 1 + fi + + # Asserted with an explicit `if`, not `! grep -q`: `set -e` exempts a + # negated command, so that form only fails the step by virtue of being the + # LAST line — appending so much as a debug echo would silently disarm it. + # The positive control matters too: `! grep -q` also passes on an empty + # file, so without it "the Job is absent" and "nothing rendered at all" + # would be indistinguishable. + - name: Render template (vocab-load skipped without a bucket) + run: | + helm template trust-release deploy/providers/kubernetes/ > /tmp/novocab.yaml + if ! grep -q "omop-db" /tmp/novocab.yaml; then + echo "::error::the default render produced no omop-db resources — the absence check below would pass vacuously" + exit 1 + fi + if grep -q "omop-vocab-load" /tmp/novocab.yaml; then + echo "::error::the vocab-load Job rendered with no s3Bucket set — a failed hook fails the WHOLE Helm release" + exit 1 + fi + # FLIP-PT-091: the orthanc user map must stay a required secret reference. # With optional: true, an install whose Secret lacks the key boots a PACS # with no registered users instead of failing the pod. diff --git a/.github/workflows/test_trust_omop_db.yml b/.github/workflows/test_trust_omop_db.yml new file mode 100644 index 000000000..056f3a44d --- /dev/null +++ b/.github/workflows/test_trust_omop_db.yml @@ -0,0 +1,84 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: Trust - OMOP DB CI + +on: + push: + branches: [main, develop] + paths: + - "trust/omop-db/**" + - ".github/workflows/test_trust_omop_db.yml" + pull_request: + branches: [main, develop] + +permissions: + contents: read + +jobs: + omop-db: + # Lint + type-check + unit tests for the omop-db populate tooling. No + # backing services needed — the DB-touching pipeline is manual by design + # (the licensed core vocabulary cannot be fetched in CI; see + # trust/omop-db/README.md). + runs-on: ubuntu-latest + # Matches every sibling test workflow that uploads to Codecov — CODECOV_TOKEN + # is scoped to this environment. + environment: flip + defaults: + run: + working-directory: ./trust/omop-db + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + run: pip install uv + + - name: Sync omop-db-tools venv + run: uv sync --locked + + - name: Lint, type-check and unit tests + run: make local_test + + # The Kubernetes vocab-load Job unpacks the bundle with the image's own + # unzip so that it installs nothing at run time and needs no egress to a + # package mirror. That contract spans two repos' worth of distance — the + # Helm template assumes what this Dockerfile provides — so assert the + # artifact itself rather than the Dockerfile text. This gates the GHCR + # publish (docker_build_omop_db.yml runs on this workflow's success), so a + # broken image cannot reach a cluster, where the symptom would be + # `unzip: command not found` inside a failing release hook. + - name: Image carries the seed-time loader contract + run: | + docker build -t omop-db-ci-check . + docker run --rm omop-db-ci-check sh -c ' + command -v unzip > /dev/null || { echo "unzip missing from the image"; exit 1; } + test -x /flip/omop/load_core_vocab.sh || { echo "load_core_vocab.sh missing or not executable"; exit 1; } + test -f /flip/omop/constraints.sql || { echo "constraints.sql missing"; exit 1; } + /flip/omop/load_core_vocab.sh 2>&1 | grep -q "usage:" || { echo "loader did not run"; exit 1; } + ' + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: trust/omop-db/coverage-unit.xml + flags: omop-db + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b54045bad..71c2132c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,10 @@ repos: name: uv-lock (data-access-api) args: ['--check', '--project', 'trust/data-access-api'] files: '^trust/data-access-api/(pyproject\.toml|uv\.lock|uv\.toml)$' + - id: uv-lock + name: uv-lock (omop-db) + args: ['--check', '--project', 'trust/omop-db'] + files: '^trust/omop-db/(pyproject\.toml|uv\.lock|uv\.toml)$' - id: uv-lock name: uv-lock (xnat-tests) args: ['--check', '--project', 'trust/xnat/tests'] diff --git a/.secrets.baseline b/.secrets.baseline index b643d8e82..6b4a9e386 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -179,9 +179,9 @@ "filename": "deploy/providers/kubernetes/values.yaml", "hashed_secret": "440587e65c032a4ab2ed2bbd92aeadef265509dd", "is_verified": false, - "line_number": 180 + "line_number": 207 } ] }, - "generated_at": "2026-06-05T12:14:13Z" + "generated_at": "2026-07-29T18:25:08Z" } diff --git a/AGENTS.md b/AGENTS.md index e178f72ea..5c59796bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ FLIP/ │ ├── trust-api/ # Trust API gateway (Python/FastAPI) │ ├── data-access-api/# OMOP database queries (Python/FastAPI) │ ├── imaging-api/ # DICOM image retrieval (Python/FastAPI) -│ ├── omop-db/ # Mocked OMOP database (PostgreSQL) +│ ├── omop-db/ # Mocked OMOP database (PostgreSQL) + omop-db image build source & populate tooling (#834) │ ├── orthanc/ # Mocked PACS server │ └── xnat/ # Mocked XNAT neuroimaging service ├── deploy/ # Docker Compose files (dev/prod, flower/nvflare); FL network provisioning now lives under fl-services//, not here @@ -402,7 +402,7 @@ GitHub Actions: `test_flip_api.yml`, `test_flip_ui.yml`, `test_trust_*.yml`, `do ### Docker image builds: gated on tests, manual trigger for branches -**The application `docker_build_*.yml` workflows (`flip_api`, `trust_trust_api`, `trust_imaging_api`, `trust_data_access_api`) auto-publish to GHCR only after their service's test workflow passes on `develop` or `main`.** They trigger via `workflow_run` on the matching test workflow (`FLIP API CI`, `Trust - Trust API CI`, etc.) and a job-level `if` gates on `workflow_run.conclusion == 'success'` — a red test suite never publishes. Path filtering is inherited from the test workflow, so a build still only fires when that service changed. (`orthanc`, `xnat_*` keep their direct push trigger — they have no separate test workflow to gate on; `orthanc` instead runs an in-job auth smoke test between build and push, and also on PRs touching `trust/orthanc/**`, so a red smoke never publishes — FLIP-PT-091; `flip-ui` is a CI smoke test that never publishes.) +**The application `docker_build_*.yml` workflows (`flip_api`, `trust_trust_api`, `trust_imaging_api`, `trust_data_access_api`, `omop_db`) auto-publish to GHCR only after their service's test workflow passes on `develop` or `main`.** They trigger via `workflow_run` on the matching test workflow (`FLIP API CI`, `Trust - Trust API CI`, etc.) and a job-level `if` gates on `workflow_run.conclusion == 'success'` — a red test suite never publishes. Path filtering is inherited from the test workflow, so a build still only fires when that service changed. (`orthanc`, `xnat_*` keep their direct push trigger — they have no separate test workflow to gate on; `orthanc` instead runs an in-job auth smoke test between build and push, and also on PRs touching `trust/orthanc/**`, so a red smoke never publishes — FLIP-PT-091; `flip-ui` is a CI smoke test that never publishes.) Every publish also pushes an immutable **`sha-`** tag (first 7 chars of the built commit) alongside the mutable `:stag`/`:prod` tags. Hub ECS deploys pin these sha tags via task-definition revisions — `make deploy-centralhub` resolves the env branch tip's tag, `make rollback-centralhub` repoints at the previous revision (FLIP#751; see `deploy/providers/AWS/README.md` "Central Hub deploys and rollback"). `deploy-centralhub` also prints an **FL quiesce reminder** (FLIP#770; on `PROD=true` it adds an interactive are-you-sure confirmation, stag stays non-interactive): replacing `fl-server-net-1` kills any in-flight training run, so enable deployment mode first — it pauses FL job pickup (queued jobs hold; the running job finishes and frees its net) — and wait until the hub's `GET /fl/quiesce` reports deployment mode ON and no BUSY net, making "enable mode → wait → deploy → disable" the standard redeploy workflow. diff --git a/CLAUDE.md b/CLAUDE.md index c10186431..2f49a9768 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ FLIP/ │ ├── trust-api/ # Trust API gateway (Python/FastAPI) │ ├── data-access-api/# OMOP database queries (Python/FastAPI) │ ├── imaging-api/ # DICOM image retrieval (Python/FastAPI) -│ ├── omop-db/ # Mocked OMOP database (PostgreSQL) +│ ├── omop-db/ # Mocked OMOP database (PostgreSQL) + omop-db image build source & populate tooling (#834) │ ├── orthanc/ # Mocked PACS server │ └── xnat/ # Mocked XNAT neuroimaging service ├── deploy/ # Docker Compose files (dev/prod, flower/nvflare); FL network provisioning now lives under fl-services//, not here @@ -402,7 +402,7 @@ GitHub Actions: `test_flip_api.yml`, `test_flip_ui.yml`, `test_trust_*.yml`, `do ### Docker image builds: gated on tests, manual trigger for branches -**The application `docker_build_*.yml` workflows (`flip_api`, `trust_trust_api`, `trust_imaging_api`, `trust_data_access_api`) auto-publish to GHCR only after their service's test workflow passes on `develop` or `main`.** They trigger via `workflow_run` on the matching test workflow (`FLIP API CI`, `Trust - Trust API CI`, etc.) and a job-level `if` gates on `workflow_run.conclusion == 'success'` — a red test suite never publishes. Path filtering is inherited from the test workflow, so a build still only fires when that service changed. (`orthanc`, `xnat_*` keep their direct push trigger — they have no separate test workflow to gate on; `orthanc` instead runs an in-job auth smoke test between build and push, and also on PRs touching `trust/orthanc/**`, so a red smoke never publishes — FLIP-PT-091; `flip-ui` is a CI smoke test that never publishes.) +**The application `docker_build_*.yml` workflows (`flip_api`, `trust_trust_api`, `trust_imaging_api`, `trust_data_access_api`, `omop_db`) auto-publish to GHCR only after their service's test workflow passes on `develop` or `main`.** They trigger via `workflow_run` on the matching test workflow (`FLIP API CI`, `Trust - Trust API CI`, etc.) and a job-level `if` gates on `workflow_run.conclusion == 'success'` — a red test suite never publishes. Path filtering is inherited from the test workflow, so a build still only fires when that service changed. (`orthanc`, `xnat_*` keep their direct push trigger — they have no separate test workflow to gate on; `orthanc` instead runs an in-job auth smoke test between build and push, and also on PRs touching `trust/orthanc/**`, so a red smoke never publishes — FLIP-PT-091; `flip-ui` is a CI smoke test that never publishes.) Every publish also pushes an immutable **`sha-`** tag (first 7 chars of the built commit) alongside the mutable `:stag`/`:prod` tags. Hub ECS deploys pin these sha tags via task-definition revisions — `make deploy-centralhub` resolves the env branch tip's tag, `make rollback-centralhub` repoints at the previous revision (FLIP#751; see `deploy/providers/AWS/README.md` "Central Hub deploys and rollback"). `deploy-centralhub` also prints an **FL quiesce reminder** (FLIP#770; on `PROD=true` it adds an interactive are-you-sure confirmation, stag stays non-interactive): replacing `fl-server-net-1` kills any in-flight training run, so enable deployment mode first — it pauses FL job pickup (queued jobs hold; the running job finishes and frees its net) — and wait until the hub's `GET /fl/quiesce` reports deployment mode ON and no BUSY net, making "enable mode → wait → deploy → disable" the standard redeploy workflow. diff --git a/Makefile b/Makefile index 533e9226b..ff9a28027 100644 --- a/Makefile +++ b/Makefile @@ -376,6 +376,7 @@ unit_test: $(MAKE) -C flip-ui unit_test $(MAKE) -C trust/data-access-api unit_test $(MAKE) -C trust/imaging-api unit_test + $(MAKE) -C trust/omop-db unit_test $(MAKE) -C trust/trust-api unit_test $(MAKE) -C trust/xnat unit_test @@ -384,7 +385,7 @@ integration_test: $(MAKE) -C trust integration_test # Python projects managed by uv; each has its own pyproject.toml + uv.lock. -UV_PROJECTS := . flip-api docs trust/trust-api trust/imaging-api trust/data-access-api trust/xnat/tests deploy/providers/AWS +UV_PROJECTS := . flip-api docs trust/trust-api trust/imaging-api trust/data-access-api trust/omop-db trust/xnat/tests deploy/providers/AWS # Regenerate every uv.lock so it matches its pyproject.toml. Run after changing # dependencies in any service, or to refresh all lockfiles in one pass. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a26d3de04..e3c07763d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,3 +8,30 @@ Copyright (c) 2020, Washington University School of Medicine Licensed under the BSD 2-Clause License. See trust/xnat/LICENSE.txt. + +## OHDSI CommonDataModel + +The OMOP CDM 5.4 PostgreSQL DDL files under trust/omop-db/files/ +(OMOPCDM_postgresql_5.4_*.sql) are derived from the OHDSI CommonDataModel +project (https://github.com/OHDSI/CommonDataModel, inst/ddl/5.4/postgresql), +extended with the MI-CDM medical-imaging tables (image_occurrence, +image_feature). + +Copyright (c) Observational Health Data Sciences and Informatics (OHDSI) + +Licensed under the Apache License, Version 2.0. + +## DICOM2OMOP + +The DICOM vocabulary loader (trust/omop-db/src/omop_db_tools/load_dicom_vocab.py) +is adapted from the DICOM2OMOP project +(https://github.com/paulnagy/DICOM2OMOP), and the DICOM vocabulary bundle it +loads (vocab_dicom_paulnagy_20260109) is a byte-for-byte copy of four files +from that project's "files/OMOP CDM Staging/" directory at commit +1ef3354c0a1b335dcc7934717b1946e76dda94b8, generated by that project from the +NEMA DICOM standard (PS3); the relationship frame is converted from the +upstream pickle to CSV in the published copy. + +Copyright (c) Paul Nagy + +Licensed under the Apache License, Version 2.0. diff --git a/deploy/providers/AWS/Makefile b/deploy/providers/AWS/Makefile index 2ed705d22..f2107613d 100644 --- a/deploy/providers/AWS/Makefile +++ b/deploy/providers/AWS/Makefile @@ -440,7 +440,13 @@ ansible-init: ## Initialize Ansible configuration on EC2 instances (if failed tr @[ -n "$(ORTHANC_DATA_VERSION)" ] || (echo "❌ trust/orthanc/.data_version not found"; exit 1) @uv sync @uv run ansible-galaxy install -r requirements.yml + @# --skip-tags omop-vocab: the vocabulary load needs the trust's OMOP cluster + @# credentials, which live in the kit file. This target runs BEFORE + @# register-trusts in every deploy chain, so it has no KIT in scope (hence + @# trust_num falling back to 1 above) and cannot supply them. The vocabulary + @# is seeded later by `make seed-trust-data KIT=`, which does have them. @unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_SECURITY_TOKEN; uv run ansible-playbook site.yml \ + --skip-tags omop-vocab \ -e flip_aicentre_bucket=$(AICENTRE_BUCKET_NAME) \ -e fl_backend=$(FL_BACKEND) \ -e fl_kit_date=$(FL_KIT_DATE) \ @@ -475,6 +481,7 @@ seed-trust-data: check-trust-ec2-enabled ssh-config ## Load trust-specific OMOP @[ -n "$(FL_KIT_SLOT_NUMBER)" ] || (echo "❌ FL_KIT_SLOT_NUMBER not found in trust/.env.$(KIT).$(KIT_ENV_SUFFIX) — run register-trusts first"; exit 1) @[ -n "$(OMOP_DATA_VERSION)" ] || (echo "❌ trust/omop-db/.data_version not found"; exit 1) @[ -n "$(ORTHANC_DATA_VERSION)" ] || (echo "❌ trust/orthanc/.data_version not found"; exit 1) + @[ -n "$(OMOP_POSTGRES_PASSWORD_KIT)" ] || (echo "❌ OMOP_POSTGRES_PASSWORD not found in trust/.env.$(KIT).$(KIT_ENV_SUFFIX) — the vocab-load play needs the cluster credentials"; exit 1) @echo "🗄️ Seeding Trust EC2 with trust$(FL_KIT_SLOT_NUMBER) data (OMOP v$(OMOP_DATA_VERSION), Orthanc v$(ORTHANC_DATA_VERSION))..." @uv run ansible-playbook site.yml \ --tags data \ @@ -482,7 +489,12 @@ seed-trust-data: check-trust-ec2-enabled ssh-config ## Load trust-specific OMOP -e fl_kit_date=$(FL_KIT_DATE) \ -e trust_num=$(FL_KIT_SLOT_NUMBER) \ -e omop_data_version=$(OMOP_DATA_VERSION) \ - -e orthanc_data_version=$(ORTHANC_DATA_VERSION) + -e orthanc_data_version=$(ORTHANC_DATA_VERSION) \ + -e omop_postgres_user=$(OMOP_POSTGRES_USER_KIT) \ + -e omop_postgres_password=$(OMOP_POSTGRES_PASSWORD_KIT) \ + -e omop_postgres_db=$(OMOP_POSTGRES_DB_KIT) \ + -e vocab_s3_bucket=$(AICENTRE_BUCKET_NAME) \ + -e omop_db_tag=$(DOCKER_TAG) @echo "✅ Trust data seeding complete." .PHONY: deploy-centralhub @@ -752,6 +764,12 @@ deploy-ui: ## Build flip-ui from the working tree and deploy it to S3 + invalida FL_KIT_SLOT := $(shell sed -n 's/^FL_KIT_SLOT=//p' ../../../trust/.env.$(KIT).$(KIT_ENV_SUFFIX) 2>/dev/null) FL_KIT_SLOT_NUMBER := $(shell sed -n 's/^FL_KIT_SLOT_NUMBER=//p' ../../../trust/.env.$(KIT).$(KIT_ENV_SUFFIX) 2>/dev/null) +# Trust-local OMOP DB credentials from the kit — passed to the vocab-load play +# (they must match the values the restored pgdata cluster was built with). +OMOP_POSTGRES_USER_KIT := $(shell sed -n 's/^OMOP_POSTGRES_USER=//p' ../../../trust/.env.$(KIT).$(KIT_ENV_SUFFIX) 2>/dev/null) +OMOP_POSTGRES_PASSWORD_KIT := $(shell sed -n 's/^OMOP_POSTGRES_PASSWORD=//p' ../../../trust/.env.$(KIT).$(KIT_ENV_SUFFIX) 2>/dev/null) +OMOP_POSTGRES_DB_KIT := $(shell sed -n 's/^OMOP_POSTGRES_DB=//p' ../../../trust/.env.$(KIT).$(KIT_ENV_SUFFIX) 2>/dev/null) + # Data versions read from the canonical .data_version files — these govern both # the local update-omop-data / update-orthanc-data scripts and the Ansible plays. OMOP_DATA_VERSION := $(shell cat ../../../trust/omop-db/.data_version 2>/dev/null | tr -d ' \n\r\t') diff --git a/deploy/providers/AWS/site.yml b/deploy/providers/AWS/site.yml index 07c1cbe4d..8bbe92bad 100644 --- a/deploy/providers/AWS/site.yml +++ b/deploy/providers/AWS/site.yml @@ -501,6 +501,137 @@ chmod 700 {{ omop_dir }}/db_data changed_when: false +- name: load OMOP core vocabulary on Trust EC2 + # The published pgdata tarballs are vocab-free (FLIP#842/#843): the licensed + # core vocabulary is streamed into the restored cluster here, via a throwaway + # container on a loopback-only port. The loader skips tables that already + # hold the core vocabulary, so re-runs are safe. Bundle comes from the org S3 + # bucket via the instance role. + hosts: trust_ec2 + become: true + tags: [data, omop, omop-vocab] + vars: + omop_dir: "/opt/flip/omop" + # The env's own bucket (passed by the Makefile as AICENTRE_BUCKET_NAME) — + # the instance role has no cross-account read on other envs' buckets. + vocab_s3_bucket: "{{ vocab_s3_bucket | default('flipdev-aicentre', true) }}" + vocab_core_bundle: "vocab_aicentre_core_20240916" + # Supplied by `make seed-trust-data` as the env's own DOCKER_TAG. The fallback + # only applies to a hand-run playbook: the container is a throwaway Postgres + # server over an already-restored volume, so the tag matters little here — + # 'stag' is simply the safer accident than the production image. + omop_db_image: "ghcr.io/londonaicentre/omop-db:{{ omop_db_tag | default('stag', true) }}" + vocab_load_port: "15499" + # Superuser credentials of the restored cluster — the kit's OMOP_POSTGRES_* + # values, passed as extra-vars by the Makefile seed-trust-data target. + omop_postgres_user: "{{ omop_postgres_user | default('postgres', true) }}" + omop_postgres_db: "{{ omop_postgres_db | default('trustomopdb', true) }}" + tasks: + - name: assert the cluster password extra-var is set + assert: + that: + - omop_postgres_password is defined + - omop_postgres_password | length > 0 + fail_msg: "omop_postgres_password is required (the kit's OMOP_POSTGRES_PASSWORD; passed by make seed-trust-data)" + + - name: install postgresql-client and unzip + apt: + name: + - postgresql-client + - unzip + state: present + update_cache: yes + + # Everything from the S3 fetch onwards is wrapped so the `always:` cleanup runs + # even when the unzip, the container, the readiness wait or the loader fails. + # Without it a mid-play failure leaves the licensed bundle at rest on the host. + # + # Both stages stage into a dot-prefixed temp path and rename only on success, + # so an interrupted download or extraction is never mistaken for a present + # bundle by the next run's `creates:` guard. Same idiom as `fetch-vocab-core` + # in trust/omop-db/Makefile. + - name: load the core vocabulary, cleaning up whatever happens + block: + - name: download core vocabulary bundle from S3 (instance role) + shell: | + set -euo pipefail + aws s3 cp "s3://{{ vocab_s3_bucket }}/vocab/{{ vocab_core_bundle }}.zip" "{{ omop_dir }}/.{{ vocab_core_bundle }}.zip.tmp" + mv "{{ omop_dir }}/.{{ vocab_core_bundle }}.zip.tmp" "{{ omop_dir }}/{{ vocab_core_bundle }}.zip" + args: + creates: "{{ omop_dir }}/{{ vocab_core_bundle }}.zip" + executable: /bin/bash + + - name: unzip core vocabulary bundle + shell: | + set -euo pipefail + rm -rf "{{ omop_dir }}/.{{ vocab_core_bundle }}.tmp" + unzip -q "{{ omop_dir }}/{{ vocab_core_bundle }}.zip" -d "{{ omop_dir }}/.{{ vocab_core_bundle }}.tmp" + mv "{{ omop_dir }}/.{{ vocab_core_bundle }}.tmp" "{{ omop_dir }}/{{ vocab_core_bundle }}" + args: + creates: "{{ omop_dir }}/{{ vocab_core_bundle }}" + executable: /bin/bash + + - name: copy vocabulary loader and constraints from the repo checkout + copy: + src: "../../../trust/omop-db/files/{{ item }}" + dest: "{{ omop_dir }}/{{ item }}" + mode: "0755" + loop: + - load_core_vocab.sh + - OMOPCDM_postgresql_5.4_constraints.sql + + - name: start throwaway database container on the restored volume + command: > + docker run -d --name omop-vocab-load + -v {{ omop_dir }}/db_data:/var/lib/postgresql/data + -p 127.0.0.1:{{ vocab_load_port }}:5432 + {{ omop_db_image }} + + - name: wait for the database to accept connections + command: docker exec omop-vocab-load pg_isready -U {{ omop_postgres_user }} + register: pg_ready + until: pg_ready.rc == 0 + retries: 30 + delay: 5 + changed_when: false + + - name: stream the core vocabulary and apply constraints + command: bash {{ omop_dir }}/load_core_vocab.sh {{ omop_dir }}/{{ vocab_core_bundle }} {{ omop_dir }}/OMOPCDM_postgresql_5.4_constraints.sql + environment: + OMOP_DB_HOST: "127.0.0.1" + OMOP_DB_PORT: "{{ vocab_load_port }}" + OMOP_POSTGRES_USER: "{{ omop_postgres_user }}" + OMOP_POSTGRES_PASSWORD: "{{ omop_postgres_password }}" + OMOP_POSTGRES_DB: "{{ omop_postgres_db }}" + always: + # Deleting the licensed content comes FIRST: a failing task in `always:` + # aborts the remaining `always:` tasks, so with the container removal + # ahead of it a `docker rm` against an unreachable daemon would leave + # ~3.6GB of licensed vocabulary at rest on the host — exactly what this + # task exists to prevent. The temp paths are included so an interrupted + # download or extraction leaves nothing behind either. + - name: remove the downloaded bundle (~3.6GB of licensed content does not belong at rest on the host) + file: + path: "{{ item }}" + state: absent + loop: + - "{{ omop_dir }}/{{ vocab_core_bundle }}.zip" + - "{{ omop_dir }}/{{ vocab_core_bundle }}" + - "{{ omop_dir }}/.{{ vocab_core_bundle }}.zip.tmp" + - "{{ omop_dir }}/.{{ vocab_core_bundle }}.tmp" + + # This runs even when the container was never started. `docker rm -f` + # already exits 0 on a missing container (since Docker 23), so only the + # "No such container" case is tolerated explicitly — a genuine failure + # (unreachable daemon, un-removable container) must still fail the play: + # it would leave a Postgres container holding {{ omop_dir }}/db_data and + # the loopback port, which the real omop-db later mounts. + - name: stop the throwaway container + command: docker rm -f omop-vocab-load + register: vocab_rm + changed_when: false + failed_when: vocab_rm.rc != 0 and 'No such container' not in (vocab_rm.stderr | default('')) + - name: restore Orthanc storage on Trust EC2 hosts: trust_ec2 become: true diff --git a/deploy/providers/kubernetes/README.md b/deploy/providers/kubernetes/README.md index 54ff6422c..307def5e8 100644 --- a/deploy/providers/kubernetes/README.md +++ b/deploy/providers/kubernetes/README.md @@ -253,6 +253,58 @@ omopDb: When `enabled: false`, the chart creates an `ExternalName` Service pointing to the external host instead of deploying the service itself. +### OMOP core vocabulary + +The `omop-db` image and the pgdata archive restored by `omopDb.initJob` are both +**vocab-free** (FLIP#842/843). The licensed core vocabulary — SNOMED CT, LOINC, +Read v2, dm+d — is streamed in afterwards by the `omop-vocab-load` +post-install/post-upgrade hook. + +That bundle cannot be publicly mirrored, so unlike `initJob` there is **no +anonymous fallback**. The hook runs only when `omopDb.vocabLoad.s3Bucket` names +a bucket the cluster can read; the chart default is empty, so a default install +succeeds with **no vocabulary loaded** (`helm install` prints a warning). + +> **Cohort queries that join `omop.concept` return nothing until the vocabulary +> is loaded.** The stack passes every health check in this state — the only +> symptom is empty cohorts. + +Two ways to load it: + +| You have… | Do this | +| --- | --- | +| Org S3 access | `make -C deploy/providers/kubernetes sync-kit KIT= PROD=` writes `omopDb.vocabLoad.s3Bucket` from the kit's `AICENTRE_BUCKET_NAME`, then `make -C deploy/providers/kubernetes deploy-trust-k8s KIT=`. **Check the kit carries your own environment's bucket** — it is not a hub-managed key, so a kit scaffolded from `trust/.env.example` ships the dev one, and trust roles have no cross-account read. | +| Your own licences | Build an equivalent bundle from [OHDSI Athena](https://athena.ohdsi.org/) / [NHS TRUD](https://isd.digital.nhs.uk/) (see `trust/omop-db/README.md`), put it in a bucket you control, and set `omopDb.vocabLoad.s3Bucket` / `bundleName`. Or run `trust/omop-db/files/load_core_vocab.sh` against the database directly. | + +Run both targets with `-C deploy/providers/kubernetes` (or from that directory): +the repo-root `make sync-kit` does not exist, and the root `deploy-trust-k8s` +forwards to the chart's plain `deploy` target, so `KIT=` never reaches the +per-trust override file. + +AWS credentials for the fetch are shared with the init job: +`omopDb.initJob.awsProfile` and `omopDb.initJob.hostAwsMount` (enable the host +`~/.aws` mount for local clusters; use IRSA on EKS). Note `awsProfile` only +reaches this Job when `hostAwsMount` is enabled. + +The hook probes before it fetches: `probe-vocab` asks the database what is +missing, and only if something is does `fetch-bundle` download the bundle. This +matters because the hook is on the critical path of *every* `helm upgrade` and a +failed hook fails the whole release — so an upgrade that changes an unrelated +image tag costs two queries per vocabulary table (the probe's guards, then the +loader's) plus a pass over the constraint catalogue, not a multi-GB download. The +download lands in an `emptyDir` sized by `omopDb.vocabLoad.workDirSize` (10Gi, +enough for the zip and its unpacked contents together); lower it, and the +matching `fetchResources` / `loadResources` requests, for a cluster with small +nodes. Keep the container `ephemeral-storage` limits above `workDirSize` too: +emptyDir usage is charged to the pod, whose ceiling is the regular containers' +limits summed with each init container's taken as a max against that total — so +here it is 12Gi, not 3 × 12Gi. Limits below the work dir size would evict the pod +before it had finished filling it. + +An external OMOP database (`omopDb.enabled: false`) already skips this Job. Set +`omopDb.vocabLoad.enabled: false` only to keep an in-cluster `omop-db` while +loading the vocabulary by hand. + ### FL Backend Configuration Switch between NVFLARE and Flower: @@ -519,6 +571,41 @@ Symptoms: trust-api can't reach imaging-api or data-access-api (connection timeo - PVC name must match the StatefulSet's `volumeClaimTemplates` — the Job expects a PVC named `-omop-db-data`. - To re-run: `helm upgrade trust-release . --set omopDb.initJob.enabled=true` or delete the Job and let Helm re-create it. +**OMOP vocabulary load** (`omop-vocab-load`): + +- Cohorts come back empty but every pod is healthy → the vocabulary was never loaded. + Check with `kubectl get job -n -l app.kubernetes.io/component=omop-vocab-load`. + **No Job at all** means `omopDb.vocabLoad.s3Bucket` is empty and the hook was skipped + by design — see "OMOP core vocabulary" above. +- `aws s3 cp` denied in the `fetch-bundle` initContainer → wrong bucket for this + environment (each env reads its own; no cross-account read), or no credentials + (`omopDb.initJob.hostAwsMount` for local clusters, IRSA on EKS). +- `/flip/omop/load_core_vocab.sh: No such file or directory` → the `omopDb.image.tag` + in use predates FLIP#842; repull a CI-published tag. +- Re-running `helm upgrade` is safe *and* cheap. The Job runs three stages — + `probe-vocab` asks the database what is missing, `fetch-bundle` downloads the + bundle only if something is, then `load-vocab` loads it. On an upgrade where the + vocabulary is already loaded the probe logs `Core vocabulary already present in + every table`, the fetch logs `skipping bundle fetch`, and no multi-GB download + happens. The loader still runs (it re-applies the FK constraints, so a previous + run that died between the load and the constraints heals here). +- `probe-vocab` fails with `omop-db not reachable after 60 attempts` → the database + never became ready within five minutes. This is a hard failure: the Pod fails, + and after `backoffLimit` so does the release. Check the `omop-db` pod and the + init Job that restores its PVC. (`load-vocab` waits the same way and fails the + same way, which is what stops a database restart during a long download from + discarding the bundle that was just fetched.) +- A database that *is* reachable but cannot answer the probe — wrong password, + `omop` schema absent — is treated differently: the probe leaves its marker + unwritten and the Job falls through to a full fetch-and-load rather than + failing, because a needless download is recoverable and a wrongly-skipped load + is silent. The loader then reports the real error. +- The Job reaches no host but S3. `fetch-bundle` only downloads the zip; the + loader unpacks it with the `unzip` baked into the `omop-db` image, so nothing + is installed at run time and no package mirror has to be on the egress + allowlist. `unzip: not found` in `load-vocab` means the `omopDb.image.tag` in + use predates this — repull a CI-published tag. + ### Getting help If the above doesn't resolve your issue, please open a GitHub issue at: diff --git a/deploy/providers/kubernetes/ci/test-values.yaml b/deploy/providers/kubernetes/ci/test-values.yaml index cf4af3387..a13a55746 100644 --- a/deploy/providers/kubernetes/ci/test-values.yaml +++ b/deploy/providers/kubernetes/ci/test-values.yaml @@ -58,6 +58,13 @@ orthanc: initialDelaySeconds: 10 omopDb: + # Belt and braces: the chart already skips the vocab-load hook when + # vocabLoad.s3Bucket is empty (the default), so CI installs vocab-less without + # this. Pinned off so a future default bucket can never make CI reach for org + # credentials — the licensed bundle is neither available nor licence-appropriate + # in CI. + vocabLoad: + enabled: false persistence: size: 1Gi probes: diff --git a/deploy/providers/kubernetes/sync_k8s_kit.py b/deploy/providers/kubernetes/sync_k8s_kit.py index 54713151f..08851d5dd 100755 --- a/deploy/providers/kubernetes/sync_k8s_kit.py +++ b/deploy/providers/kubernetes/sync_k8s_kit.py @@ -220,6 +220,20 @@ def render_override(kit: dict[str, str], code: str, aws_region: str) -> str: "", ] + # OMOP core vocabulary (FLIP#842/843): the licensed bundle cannot be mirrored + # publicly, so the chart runs its vocab-load hook only when a readable bucket + # is named — the chart default is empty, and a release installed without this + # has NO vocabulary (cohort queries joining omop.concept return nothing). + # Each environment reads its OWN bucket (no cross-account read), which is + # exactly what AICENTRE_BUCKET_NAME carries. + if kit_bucket: + lines += [ + "omopDb:", + " vocabLoad:", + f" s3Bucket: {kit_bucket}", + "", + ] + # fl-client kit S3 bucket + slot-aware path. The NVFLARE kit is published # under .../net-1/services/, where is the FL kit slot the hub # assigned (e.g. Trust_2) — NOT the cosmetic trustName. Pin the path to the diff --git a/deploy/providers/kubernetes/templates/NOTES.txt b/deploy/providers/kubernetes/templates/NOTES.txt new file mode 100644 index 000000000..e7910d842 --- /dev/null +++ b/deploy/providers/kubernetes/templates/NOTES.txt @@ -0,0 +1,43 @@ +{{/* +Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} +{{ include "flip-trust.fullname" . }} installed into namespace {{ include "flip-trust.namespace" . }}. + +Trust: {{ .Values.trustName }} (slot {{ .Values.trustNumber }}) + +Check the stack is up: + kubectl get pods -n {{ include "flip-trust.namespace" . }} +{{- if and .Values.omopDb.enabled .Values.omopDb.vocabLoad.enabled (not .Values.omopDb.vocabLoad.s3Bucket) }} + +⚠️ OMOP core vocabulary was NOT loaded + + omopDb.vocabLoad.s3Bucket is empty, so the omop-vocab-load hook was skipped. + + The omop-db image and the restored pgdata are vocab-free by design: the + licensed bundle (SNOMED CT / LOINC / Read v2 / dm+d) cannot be published, + so this chart will not fetch it for you. + + Until it is loaded, cohort queries that join omop.concept return NOTHING — + the stack looks healthy and silently matches no patients. + + Load it by either: + * setting omopDb.vocabLoad.s3Bucket to a bucket this cluster can read + (your OWN environment's AICENTRE_BUCKET_NAME — trust roles have no + cross-account read) and re-running `helm upgrade`. From the repo root: + make -C deploy/providers/kubernetes sync-kit KIT= PROD= + make -C deploy/providers/kubernetes deploy-trust-k8s KIT= + or + * running trust/omop-db/files/load_core_vocab.sh against the database + with your own OHDSI Athena / NHS TRUD export. + + See deploy/providers/kubernetes/README.md → "OMOP core vocabulary". +{{- end }} diff --git a/deploy/providers/kubernetes/templates/_helpers.tpl b/deploy/providers/kubernetes/templates/_helpers.tpl index ef903bdc7..98918744c 100644 --- a/deploy/providers/kubernetes/templates/_helpers.tpl +++ b/deploy/providers/kubernetes/templates/_helpers.tpl @@ -94,6 +94,28 @@ Namespace name {{- end }} {{- end }} +{{/* +OMOP database connection environment, shared by the vocab-load Job's probe and +loader containers. Both talk to the same database with the same credentials — +keeping one definition means a rename here cannot leave the probe reading a +different database than the loader writes to. +*/}} +{{- define "flip-trust.omopVocabDbEnv" -}} +- name: OMOP_DB_HOST + value: "omop-db" +- name: OMOP_DB_PORT + value: "5432" +- name: OMOP_POSTGRES_USER + value: {{ .Values.omopDb.credentials.user | quote }} +- name: OMOP_POSTGRES_DB + value: {{ .Values.dataAccessApi.env.OMOP_POSTGRES_DB | quote }} +- name: OMOP_POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} + key: omop-postgres-password +{{- end }} + {{/* FL client image name based on backend selection */}} diff --git a/deploy/providers/kubernetes/templates/omop-db-vocab-load-job.yaml b/deploy/providers/kubernetes/templates/omop-db-vocab-load-job.yaml new file mode 100644 index 000000000..c572fa19e --- /dev/null +++ b/deploy/providers/kubernetes/templates/omop-db-vocab-load-job.yaml @@ -0,0 +1,200 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The published omop-db image and pgdata tarballs are vocab-free (FLIP#842/843): +# this Job streams the licensed core vocabulary bundle into the running database +# after install/upgrade, using the image's own loader. +# +# It runs in three stages, and the order is load-bearing: PROBE the database, +# FETCH the bundle only if the probe says something is missing, then LOAD. The +# loader is a cheap no-op when the vocabulary is already there, but the fetch is +# a multi-GB download into a per-pod emptyDir — and this is a post-upgrade HOOK, +# so an unconditional fetch would put that download on the critical path of every +# `helm upgrade` (a failed hook fails the WHOLE release). Probing first makes a +# routine upgrade cost two round-trips per vocabulary table — the probe's guards +# and the loader's — plus a pass over the constraint catalogue. +# +# The loader still runs when the probe finds everything present: it re-checks the +# same guards, loads nothing, and applies the FK constraints — which is how a +# previous run that loaded every table and then died before applying them heals +# on the next upgrade. +# +# Gated on s3Bucket as well as enabled — deliberately UNLIKE the sibling +# omop-db-init-job, which renders unconditionally because it can fall back to an +# anonymous Hugging Face download when its bucket is empty. The licensed bundle +# has no such mirror, so with no bucket there is nothing this Job could do: no +# bucket ⇒ no Job ⇒ the release installs cleanly. NOTES.txt announces the skip so +# it is not silent. +{{- if and .Values.omopDb.enabled .Values.omopDb.vocabLoad.enabled .Values.omopDb.vocabLoad.s3Bucket }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "flip-trust.fullname" . }}-omop-vocab-load + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} + app.kubernetes.io/component: omop-vocab-load + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "5" + # No `hook-succeeded`, unlike omop-db-init-job: the failure mode of a missing + # vocabulary is silence (cohort queries return nothing), so the completed Job + # is the operator's evidence that the load ran and what it skipped. Exactly + # one lingers — the next upgrade replaces it. + "helm.sh/hook-delete-policy": before-hook-creation +spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "flip-trust.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: omop-vocab-load + spec: + restartPolicy: Never + enableServiceLinks: false + {{- if .Values.imagePullSecrets }} + imagePullSecrets: +{{ include "flip-trust.imagePullSecrets" . | nindent 8 }} + {{- end }} + initContainers: + - name: probe-vocab + image: "{{ .Values.omopDb.image.repository }}:{{ .Values.omopDb.image.tag }}" + imagePullPolicy: {{ .Values.omopDb.image.pullPolicy }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + # Timing out here fails the Pod, and after backoffLimit the whole + # release — deliberately: a database that never comes up is not a + # state this Job can load into. + attempts=0 + until pg_isready -h "${OMOP_DB_HOST}" -p "${OMOP_DB_PORT}" > /dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 60 ]; then + echo "omop-db not reachable after ${attempts} attempts" >&2 + exit 1 + fi + sleep 5 + done + # --check exits 0 only when every table already holds the core + # vocabulary. Anything else — including a database that cannot be + # queried — leaves the marker unwritten, so the fetch goes ahead: + # a needless download is recoverable, a skipped load is silent. + # The else branch only logs: this container reports Completed + # either way, so without it "the vocabulary is genuinely absent" + # and "the probe could not reach the database" look identical in + # `kubectl get pods` and differ only in stderr nobody reads. + if /flip/omop/load_core_vocab.sh --check; then + touch /vocab-work/.vocab-present + else + echo "probe could not confirm the vocabulary (status $?) — falling through to a full fetch and load" >&2 + fi + env: + {{- include "flip-trust.omopVocabDbEnv" . | nindent 12 }} + resources: + {{- toYaml .Values.omopDb.vocabLoad.loadResources | nindent 12 }} + volumeMounts: + - name: vocab-work + mountPath: /vocab-work + - name: fetch-bundle + image: amazon/aws-cli:2.22.0 + command: + - /bin/sh + - -c + - | + set -euo pipefail + if [ -f /vocab-work/.vocab-present ]; then + echo "Core vocabulary already loaded — skipping bundle fetch." + exit 0 + fi + # Download only. Unpacking is the loader's job because unzip lives + # in the omop-db image and not in this one — which is what keeps + # this step free of any package install, and therefore of any + # dependency on egress to a package mirror. S3 is the only host it + # has to reach. + # Nothing to resume from on a retry: restartPolicy is Never, so a + # failed attempt is replaced by a new pod with a new emptyDir. The + # skip that saves the download is the probe above. + aws s3 cp "s3://{{ .Values.omopDb.vocabLoad.s3Bucket }}/vocab/{{ .Values.omopDb.vocabLoad.bundleName }}.zip" /vocab-work/bundle.zip + {{- if .Values.omopDb.initJob.hostAwsMount.enabled }} + env: + - name: AWS_DEFAULT_REGION + value: {{ default "eu-west-2" .Values.awsRegion | quote }} + - name: AWS_PROFILE + value: {{ .Values.omopDb.initJob.awsProfile | quote }} + {{- end }} + resources: + {{- toYaml .Values.omopDb.vocabLoad.fetchResources | nindent 12 }} + volumeMounts: + - name: vocab-work + mountPath: /vocab-work + {{- if .Values.omopDb.initJob.hostAwsMount.enabled }} + - name: host-aws + mountPath: /root/.aws + readOnly: true + {{- end }} + containers: + - name: load-vocab + image: "{{ .Values.omopDb.image.repository }}:{{ .Values.omopDb.image.tag }}" + imagePullPolicy: {{ .Values.omopDb.image.pullPolicy }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + # Waits again rather than trusting the probe's gate: on the path + # where this container does real work, the fetch between them was a + # multi-GB download, so minutes can have passed — long enough for + # omop-db to be rescheduled or to finish a rolling update. The + # release's own rollout may still be in flight in any case, because + # the chart deploys with `helm upgrade --install` and no --wait. + # Costs one round-trip when the database is already up, and what it + # protects is the most expensive artifact in the Job: failing here + # discards the Pod, and with it the emptyDir holding the bundle. + attempts=0 + until pg_isready -h "${OMOP_DB_HOST}" -p "${OMOP_DB_PORT}" > /dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 60 ]; then + echo "omop-db not reachable after ${attempts} attempts" >&2 + exit 1 + fi + sleep 5 + done + BUNDLE="{{ .Values.omopDb.vocabLoad.bundleName }}" + # No zip means the probe found the vocabulary already loaded and + # the fetch skipped itself. The bundle directory is then absent + # too, which is fine: the loader only reads it for tables it is + # actually loading, so this run still applies the constraints. + if [ -f /vocab-work/bundle.zip ]; then + unzip -q /vocab-work/bundle.zip -d "/vocab-work/${BUNDLE}" + rm -f /vocab-work/bundle.zip + fi + /flip/omop/load_core_vocab.sh "/vocab-work/${BUNDLE}" /flip/omop/constraints.sql + env: + {{- include "flip-trust.omopVocabDbEnv" . | nindent 12 }} + resources: + {{- toYaml .Values.omopDb.vocabLoad.loadResources | nindent 12 }} + volumeMounts: + - name: vocab-work + mountPath: /vocab-work + volumes: + - name: vocab-work + emptyDir: + sizeLimit: {{ .Values.omopDb.vocabLoad.workDirSize }} + {{- if .Values.omopDb.initJob.hostAwsMount.enabled }} + - name: host-aws + hostPath: + path: {{ required "omopDb.initJob.hostAwsMount.hostPath is required when hostAwsMount is enabled — set it to the AWS config dir ON THE NODE (on kind, the path an extraMount maps ~/.aws to), e.g. --set omopDb.initJob.hostAwsMount.hostPath=$HOME/.aws" .Values.omopDb.initJob.hostAwsMount.hostPath | quote }} + type: Directory + {{- end }} +{{- end }} diff --git a/deploy/providers/kubernetes/tests/test_sync_k8s_kit.py b/deploy/providers/kubernetes/tests/test_sync_k8s_kit.py index 6bdfc4747..09de888f6 100644 --- a/deploy/providers/kubernetes/tests/test_sync_k8s_kit.py +++ b/deploy/providers/kubernetes/tests/test_sync_k8s_kit.py @@ -91,5 +91,31 @@ def test_render_override_omits_block_without_fl_port(): assert "allowedEgressPorts" not in out +def test_render_override_sets_vocab_load_bucket_from_kit(): + """The chart's vocab-load hook is gated on omopDb.vocabLoad.s3Bucket, whose + default is empty (the licensed bundle has no public mirror — FLIP#842/843). + The override must supply the env's OWN bucket, or the deployment installs + cleanly with no vocabulary and cohort queries silently match nothing.""" + out = sync_k8s_kit.render_override(_FL_KIT, "Trust_K8s", "eu-west-2") + # Anchored on newlines, not loose substrings: the nesting IS the meaning here. + # Emitted one level deeper (e.g. folded into the preceding trustApi block) each + # fragment would still match, and `trustApi` is additionalProperties:true in + # values.schema.json — so Helm would silently accept the misplaced key. + assert "\nomopDb:\n vocabLoad:\n s3Bucket: flipstag-aicentre\n" in out + + +def test_render_override_omits_vocab_load_without_bucket(): + """No AICENTRE_BUCKET_NAME in the kit ⇒ no vocabLoad block, leaving the chart's + empty default in place (rather than emitting an empty bucket that reads as a + configured one).""" + kit = {k: v for k, v in _FL_KIT.items() if k != "AICENTRE_BUCKET_NAME"} + out = sync_k8s_kit.render_override(kit, "Trust_K8s", "eu-west-2") + # Not `"omopDb:" not in out` — that would break spuriously the day an unrelated + # omopDb key joins the override. s3Bucket appears nowhere else (the fl-client + # section emits a bare `bucket:`). + assert "vocabLoad" not in out + assert "s3Bucket" not in out + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/deploy/providers/kubernetes/values-override.yaml b/deploy/providers/kubernetes/values-override.yaml index 02c6b1738..a95e6b267 100644 --- a/deploy/providers/kubernetes/values-override.yaml +++ b/deploy/providers/kubernetes/values-override.yaml @@ -16,6 +16,11 @@ uploadedFederatedDataBucket: "s3://${FLIP_BUCKET_NAME}/uploaded_federated_data" omopDb: initJob: s3Bucket: flipstag-aicentre + # The licensed core vocabulary bundle — same bucket, same credentials as + # initJob. Chart default is empty; without this the release still installs, + # but with NO vocabulary (cohort queries joining omop.concept return nothing). + vocabLoad: + s3Bucket: flipstag-aicentre flClient: nvflare: kitFromS3: diff --git a/deploy/providers/kubernetes/values.schema.json b/deploy/providers/kubernetes/values.schema.json index 60ccb894a..9ce746c52 100644 --- a/deploy/providers/kubernetes/values.schema.json +++ b/deploy/providers/kubernetes/values.schema.json @@ -286,6 +286,17 @@ "additionalProperties": true, "properties": { "enabled": { "type": "boolean" }, + "vocabLoad": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "s3Bucket": { "type": "string" }, + "bundleName": { "type": "string" }, + "workDirSize": { "type": "string", "minLength": 1 }, + "fetchResources": { "type": "object", "minProperties": 1 }, + "loadResources": { "type": "object", "minProperties": 1 } + } + }, "resources": { "type": "object", "properties": { diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index a938426dd..03bc23ccb 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -355,7 +355,11 @@ omopDb: port: 5432 image: repository: ghcr.io/londonaicentre/omop-db - tag: latest + # Tracks the develop build like every other service in this file. The tag is + # load-bearing here, not cosmetic: the vocab-load Job below runs the image's + # own /flip/omop/load_core_vocab.sh, which only exists in CI-published + # (vocab-free, FLIP#842) images. + tag: stag pullPolicy: Always persistence: enabled: true @@ -372,15 +376,99 @@ omopDb: credentials: user: postgres # password is supplied via secrets.data.omop-postgres-password + # Streams the licensed core vocabulary into the running database after + # install/upgrade (the published image and pgdata tarballs are vocab-free — + # FLIP#842/843). + # + # The bundle CANNOT be publicly mirrored (SNOMED CT / LOINC / Read v2 / dm+d + # licences — see trust/omop-db/README.md), so unlike initJob there is no + # credential-free fallback. The Job therefore runs only when s3Bucket names a + # bucket this cluster can read; left empty, the release installs cleanly with + # NO vocabulary loaded. + # + # ⚠️ With no vocabulary the stack looks healthy but cohort queries that join + # omop.concept return nothing. Either set s3Bucket, or load the bundle by hand + # (chart README → "OMOP core vocabulary"). + # + # Use YOUR environment's own bucket — trust roles have no cross-account read + # on another environment's bucket. `make -C deploy/providers/kubernetes + # sync-kit KIT= PROD=` writes this from the kit's + # AICENTRE_BUCKET_NAME, so check that the kit carries the right one (it is not + # a hub-managed key — a kit scaffolded from trust/.env.example ships the dev + # bucket). + # + # AWS *credentials* are deliberately shared with initJob (initJob.awsProfile / + # initJob.hostAwsMount) — one place to configure auth, not two. Note the + # AWS_PROFILE/region env vars only reach this Job when initJob.hostAwsMount is + # enabled; on EKS it relies on IRSA. The bucket itself is a separate key per + # job, and sync-kit sets only this one. + # + # enabled: false is only for keeping an in-cluster omop-db while loading the + # vocabulary by hand — an external database (omopDb.enabled: false) already + # skips this Job. + vocabLoad: + enabled: true + s3Bucket: "" + bundleName: "vocab_aicentre_core_20240916" + # Scratch space for the downloaded bundle. Must hold the zip and its unpacked + # contents at the same time — for the 20240916 bundle that is ~3.6 GiB + # unpacked plus a ~600 MiB zip, so a peak near 4.2 GiB (both figures are in + # trust/omop-db/README.md, "The core vocabulary bundle"). The kubelet + # enforces this on the emptyDir directly; keep the pod's ephemeral-storage + # ceiling (below) above it, so that this limit is what bounds the work dir + # rather than the pod being evicted before it has finished filling it. + workDirSize: 10Gi + # The fetch initContainer downloads the zip and stops there — unpacking is + # the loader's, because unzip is in the omop-db image and not in the aws-cli + # one. Its request is small for that reason, though the pod's request is set + # by loadResources either way (see the max rule below); the zip itself lands + # in the emptyDir, which is charged to the pod and never to this container — + # a container's own ephemeral-storage accounting covers only its writable + # layer and logs. + fetchResources: + requests: + cpu: "250m" + memory: 512Mi + ephemeral-storage: 2Gi + limits: + cpu: "1" + memory: 1Gi + ephemeral-storage: 12Gi + # Shared by the probe and the loader. The work dir itself is bounded by + # workDirSize above, which the kubelet enforces on the emptyDir directly and + # independently of anything here. What the ephemeral-storage REQUEST buys is + # scheduling and eviction ranking: emptyDir usage counts against the pod, and + # the kubelet ranks eviction on usage OVER request, so a pod that requested + # nothing is picked first under node disk pressure — and an evicted run + # restarts from zero. Sized for the loader, which briefly holds the zip and + # its unpacked contents at once. The probe over-requests harmlessly: an + # ordinary init container's resources are taken as a max against the regular + # containers' total, never added to it (a restartable init container — a + # sidecar — WOULD be added, so do not convert the probe into one without + # revisiting these numbers). + # + # Limits follow that same max rule, not a sum: the pod's ceiling here is + # max(12Gi regular, 12Gi per init) = 12Gi, above workDirSize. Do not read it + # as 3 x 12Gi — setting all three to 4Gi on a sum reading would give a real + # ceiling of 4Gi and evict the pod less than halfway through the work dir. + loadResources: + requests: + cpu: "100m" + memory: 256Mi + ephemeral-storage: 6Gi + limits: + cpu: "500m" + memory: 512Mi + ephemeral-storage: 12Gi initJob: enabled: true s3Bucket: "" # Archive file name. When empty, derived from trust name + dataVersion: # {trustName}_pgdata_{dataVersion}.tar.gz # Override to point at a different archive (e.g. reuse Trust_2's data): - # archiveName: "trust2_pgdata_20260129.tar.gz" + # archiveName: "trust2_pgdata_20260729.tar.gz" archiveName: "" - dataVersion: "20260129" + dataVersion: "20260729" # AWS profile to use for S3 authentication (uses SSO cached credentials) awsProfile: "default" # Mount host's ~/.aws directory for SSO-based S3 authentication diff --git a/docs/source/components/component-omop-database.rst b/docs/source/components/component-omop-database.rst index 0757c4ad5..179719065 100644 --- a/docs/source/components/component-omop-database.rst +++ b/docs/source/components/component-omop-database.rst @@ -9,3 +9,39 @@ FLIP requires data from all participating Trusts to be stored in a standardised The `Observational Health Data Sciences and Informatics (OHDSI) `_ `Observational Medical Outcomes Partnership (OMOP) Common Data Model (CDM) `_ has been selected as the format for standardised data storage, so the database in each Trust's local instance FLIP is commonly referred to as the *OMOP Database* or *OMOP*. To prepare the standardised data to be ingested into each Trust's OMOP Database, resources are provided during the onboarding stage. + +Schema +====== + +The database implements OMOP CDM 5.4 in the ``omop`` schema, extended with the +`MI-CDM medical imaging tables `_: +``image_occurrence`` (one row per imaging study/series, carrying the ``accession_id`` +that links a cohort row to its DICOM data in the Trust PACS) and ``image_feature`` +(findings derived from those images). Cohort queries join these against the standard +clinical tables (``person``, ``visit_occurrence``, ``procedure_occurrence``, ...) — +see :ref:`the cohort query guide `. + +Access control +============== + +Cohort queries issued by the Data Access API connect as the ``data_analyst_reader`` +role: SELECT-only on the ``omop`` schema, with write/DDL rights explicitly revoked +and a statement timeout applied. These grants (``trust/omop-db/files/create_readonly_users.sql``) +are the database half of the Data Access API's SQL validation defence-in-depth. + +Mocked instance for development +=============================== + +Development and staging Trust stacks run a mocked OMOP database: +``ghcr.io/londonaicentre/omop-db``, a PostgreSQL image whose build source lives +in-repo at ``trust/omop-db/`` (schema init chain, read-only roles, vocabulary +load). Its synthetic cohort rows are maintained as a single canonical CSV +dataset on the public Hugging Face dataset +`aicentreflip/trust-data `_, +deterministically split across however many mock Trusts are stood up; dev +stacks download ready-populated database volumes from the same dataset. The +OMOP vocabularies (SNOMED CT, LOINC, ...) are licensed material and are kept +out of every published artifact — the image and data volumes ship vocab-free, +and each environment streams the vocabulary bundle it is licensed to use into +its running database as a one-time seeding step — see +``trust/omop-db/README.md`` for the build, populate and seeding workflow. diff --git a/docs/source/user-guides/user-common.rst b/docs/source/user-guides/user-common.rst index 2abe90826..0d713fe7e 100644 --- a/docs/source/user-guides/user-common.rst +++ b/docs/source/user-guides/user-common.rst @@ -182,7 +182,7 @@ Users can apply a filters to view only projects based on, for example, the curre Cohort Query ============ -Cohort data is stored within a `PostgreSQL `_ database conforming to the `standard OMOP data model `_, with the `R-CDM radiology tables `_ included. The radiology_occurrence table has been modified to include an ``accession_id`` field which contains the reference to the associated DICOM series. As this is the field that XNAT will read from when retrieving the associated DICOM series from PACS, the 'accession_id' needs to be included in all queries if relevant images are to be made available. +Cohort data is stored within a `PostgreSQL `_ database conforming to the `standard OMOP data model `_, extended with the `MI-CDM medical imaging tables `_ (``image_occurrence``, ``image_feature`` — successors of the earlier R-CDM radiology tables). The ``image_occurrence`` table has been modified to include an ``accession_id`` field which contains the reference to the associated DICOM series. As this is the field that XNAT will read from when retrieving the associated DICOM series from PACS, the 'accession_id' needs to be included in all queries if relevant images are to be made available. .. _create-cohort-query: @@ -208,7 +208,7 @@ Create Cohort Query .. code-block:: sql SELECT accession_id, concept_name, year_of_birth FROM omop.person p - JOIN omop.radiology_occurrence r ON r.Person_Id = p.Person_Id + JOIN omop.image_occurrence r ON r.Person_Id = p.Person_Id JOIN omop.concept c ON p.gender_concept_id = c.concept_id WHERE year_of_birth < 1980 @@ -577,4 +577,4 @@ The FL nets card reports the FL client-to-server connectivity for each net — t :width: 600 :align: center - Viewing the federation connection status. \ No newline at end of file + Viewing the federation connection status. diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index e8e89fe70..02adfea86 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -39,7 +39,7 @@ ORTHANC_PASSWORD=admin OMOP_POSTGRES_USER=postgres OMOP_POSTGRES_PASSWORD=omop OMOP_POSTGRES_DB=trustomopdb -OMOP_DB_TAG=latest +# OMOP_DB_TAG= # unset → DOCKER_TAG, as for the other trust images DATA_ACCESS_POSTGRES_USER=data_analyst_reader DATA_ACCESS_POSTGRES_PASSWORD=change_me_analyst_secure_password diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index c6503b4c5..1c8753d67 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -39,7 +39,7 @@ ORTHANC_PASSWORD=admin OMOP_POSTGRES_USER=postgres OMOP_POSTGRES_PASSWORD=omop OMOP_POSTGRES_DB=trustomopdb -OMOP_DB_TAG=latest +# OMOP_DB_TAG= # unset → DOCKER_TAG, as for the other trust images DATA_ACCESS_POSTGRES_USER=data_analyst_reader DATA_ACCESS_POSTGRES_PASSWORD=change_me_analyst_secure_password diff --git a/trust/.env.example b/trust/.env.example index 1119520b0..771fedaf1 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -61,7 +61,11 @@ ORTHANC_PASSWORD=admin OMOP_POSTGRES_USER=postgres OMOP_POSTGRES_PASSWORD=omop OMOP_POSTGRES_DB=trustomopdb -OMOP_DB_TAG=latest + +# omop-db image tag. Unset → DOCKER_TAG, like every other trust image. Set it +# only to pin omop-db at a known-good tag while the rest of the stack runs a +# different one (omop-db branch builds are workflow_dispatch only). +# OMOP_DB_TAG= # OMOP read-only user used by data-access-api for cohort queries. DATA_ACCESS_POSTGRES_USER=data_analyst_reader diff --git a/trust/.gitignore b/trust/.gitignore index 792423876..2d7d2fd4a 100644 --- a/trust/.gitignore +++ b/trust/.gitignore @@ -42,6 +42,8 @@ orthanc/orthanc-storage-* xnat/plugins/*.jar xnat/plugins/.s3-prefix .env.* +# ...but the omop-db build-pipeline template is documentation, not a live env file. +!omop-db/.env.build.example # Image data folder data/**/*.dcm diff --git a/trust/AGENTS.md b/trust/AGENTS.md index a8b38f926..1890f8aba 100644 --- a/trust/AGENTS.md +++ b/trust/AGENTS.md @@ -10,7 +10,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | imaging-api | 8001 | DICOM image retrieval from PACS | | data-access-api | 8010 | OMOP database queries for cohort analysis | | fl-client | — | FL participant (connects outbound to FL server via NLB) | -| omop-db | 5432 | Mocked OMOP patient database (PostgreSQL) | +| omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/AGENTS.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | | xnat | 8104 | Mocked neuroimaging platform | | observability | 3000/3100 | Grafana + Loki monitoring stack | diff --git a/trust/CLAUDE.md b/trust/CLAUDE.md index 5dfa8159f..ca6143d6f 100644 --- a/trust/CLAUDE.md +++ b/trust/CLAUDE.md @@ -10,7 +10,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | imaging-api | 8001 | DICOM image retrieval from PACS | | data-access-api | 8010 | OMOP database queries for cohort analysis | | fl-client | — | FL participant (connects outbound to FL server via NLB) | -| omop-db | 5432 | Mocked OMOP patient database (PostgreSQL) | +| omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/CLAUDE.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | | xnat | 8104 | Mocked neuroimaging platform | | observability | 3000/3100 | Grafana + Loki monitoring stack | diff --git a/trust/data-access-api/CONTRIBUTING.md b/trust/data-access-api/CONTRIBUTING.md index 465f517c8..4712d2c6d 100644 --- a/trust/data-access-api/CONTRIBUTING.md +++ b/trust/data-access-api/CONTRIBUTING.md @@ -50,7 +50,7 @@ SELECT * FROM OMOP.IMAGE_OCCURRENCE WHERE modality_concept_id = 4300757 ```sql SELECT p.gender_source_value, p.year_of_birth, r.protocol_source_value, r.manufacturer, r.accession_id FROM omop.person p -INNER JOIN omop.radiology_occurrence r ON r.person_id = p.person_id -WHERE r.radiology_occurrence_id > 200000 +INNER JOIN omop.image_occurrence r ON r.person_id = p.person_id +WHERE r.image_occurrence_id > 200000 AND p.gender_source_value = 'M' ``` diff --git a/trust/data-access-api/data_access_api/routers/schema.py b/trust/data-access-api/data_access_api/routers/schema.py index 325c336e4..42c256363 100644 --- a/trust/data-access-api/data_access_api/routers/schema.py +++ b/trust/data-access-api/data_access_api/routers/schema.py @@ -36,7 +36,7 @@ class CohortQueryInput(BaseModel): query: str = Field( ..., description="The raw SQL query to execute", - json_schema_extra={"example": "SELECT * FROM omop.radiology_occurrence"}, + json_schema_extra={"example": "SELECT * FROM omop.image_occurrence"}, ) trust_id: str = Field( ..., @@ -56,7 +56,7 @@ class DataframeQuery(BaseModel): query: str = Field( ..., description="The raw SQL query to execute", - json_schema_extra={"example": "SELECT * FROM omop.radiology_occurrence"}, + json_schema_extra={"example": "SELECT * FROM omop.image_occurrence"}, ) diff --git a/trust/data-access-api/data_access_api/services/cohort.py b/trust/data-access-api/data_access_api/services/cohort.py index c404dd820..be36a9282 100644 --- a/trust/data-access-api/data_access_api/services/cohort.py +++ b/trust/data-access-api/data_access_api/services/cohort.py @@ -78,7 +78,7 @@ def validate_query(query: str) -> str: Database-layer protections already in place ------------------------------------------- The data-access-api connects as ``data_analyst_reader`` (see - ``flip-omop-db/files/create_readonly_users.sql``), a Postgres role granted + ``trust/omop-db/files/create_readonly_users.sql``), a Postgres role granted only ``CONNECT`` + ``USAGE`` on schema ``omop`` + ``SELECT`` on its tables and sequences, with ``INSERT``, ``UPDATE``, ``DELETE``, ``TRUNCATE``, and ``CREATE`` explicitly REVOKEd. Any DDL or DML is therefore rejected by diff --git a/trust/data-access-api/tests/routers/test_cohort.py b/trust/data-access-api/tests/routers/test_cohort.py index dd215f160..db6131de0 100644 --- a/trust/data-access-api/tests/routers/test_cohort.py +++ b/trust/data-access-api/tests/routers/test_cohort.py @@ -29,7 +29,7 @@ "encrypted_project_id": "my_project", "query_id": "1", "query_name": "query_1", - "query": "SELECT * FROM omop.radiology_occurrence", + "query": "SELECT * FROM omop.image_occurrence", "trust_id": "mock_trust", } @@ -589,8 +589,8 @@ def test_validate_query_strips_trailing_semicolon(): def test_validate_query_emits_valid_select(): - result = validate_query("SELECT * FROM omop.radiology_occurrence") - assert "omop.radiology_occurrence" in result + result = validate_query("SELECT * FROM omop.image_occurrence") + assert "omop.image_occurrence" in result def test_validate_query_cte_roundtrip(): diff --git a/trust/data-access-api/tests/services/test_cohort.py b/trust/data-access-api/tests/services/test_cohort.py index e0cc03544..095d4339a 100644 --- a/trust/data-access-api/tests/services/test_cohort.py +++ b/trust/data-access-api/tests/services/test_cohort.py @@ -76,7 +76,7 @@ def test_get_statistics(mock_read_sql, mock_df): encrypted_project_id="my_project", query_id="1", query_name="query_1", - query="SELECT * FROM omop.radiology_occurrence", + query="SELECT * FROM omop.image_occurrence", trust_id="mock_trust", ) @@ -102,7 +102,7 @@ def test_get_statistics_below_threshold(mock_read_sql, mock_df_below_threshold): encrypted_project_id="my_project", query_id="2", query_name="query_2", - query="SELECT * FROM omop.radiology_occurrence WHERE omop.radiology_occurrence.manufacturer = 'Discovery'", + query="SELECT * FROM omop.image_occurrence WHERE omop.image_occurrence.manufacturer = 'Discovery'", trust_id="mock_trust", ) stats = get_statistics(mock_df_below_threshold, query_input, threshold=10) @@ -135,7 +135,7 @@ def test_get_statistics_fails_global_threshold(mock_read_sql): encrypted_project_id="my_project", query_id="3", query_name="query_3", - query="SELECT * FROM omop.radiology_occurrence", + query="SELECT * FROM omop.image_occurrence", trust_id="mock_trust", ) @@ -160,7 +160,7 @@ def test_get_statistics_genuine_zero_is_suppressed(mock_read_sql): encrypted_project_id="my_project", query_id="4", query_name="query_4", - query="SELECT * FROM omop.radiology_occurrence WHERE 1 = 0", + query="SELECT * FROM omop.image_occurrence WHERE 1 = 0", trust_id="mock_trust", ) @@ -397,7 +397,7 @@ def test_get_records_pandas_error_without_dbapi_cause(mock_read_sql): # Tests for validate_query # # DDL/DML keyword filtering is intentionally not asserted here: data-access-api -# connects as data_analyst_reader (see flip-omop-db/files/create_readonly_users.sql), +# connects as data_analyst_reader (see trust/omop-db/files/create_readonly_users.sql), # a Postgres role that has INSERT/UPDATE/DELETE/TRUNCATE/CREATE explicitly # REVOKEd. Writes are rejected at the database layer; validate_query only # enforces structural rules that the DB role cannot enforce on its own. @@ -1129,7 +1129,7 @@ def test_get_statistics_no_person_id_column(mock_read_sql): encrypted_project_id="my_project", query_id="1", query_name="no_person_id_test", - query="SELECT modality, manufacturer, accession_id FROM omop.radiology_occurrence", + query="SELECT modality, manufacturer, accession_id FROM omop.image_occurrence", trust_id="mock_trust", ) @@ -1193,7 +1193,7 @@ def read_sql_side_effect(query, *args, **kwargs): encrypted_project_id="my_project", query_id="1", query_name="with_person_id_test", - query="SELECT person_id, modality, accession_id FROM omop.radiology_occurrence", + query="SELECT person_id, modality, accession_id FROM omop.image_occurrence", trust_id="mock_trust", ) @@ -1271,7 +1271,7 @@ def read_sql_side_effect(query, *args, **kwargs): encrypted_project_id="my_project", query_id="1", query_name="low_count_test", - query="SELECT person_id, modality, accession_id FROM omop.radiology_occurrence", + query="SELECT person_id, modality, accession_id FROM omop.image_occurrence", trust_id="mock_trust", ) diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index bad4ab92f..dac7ecaa9 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -17,9 +17,15 @@ services: # --- Application services --- - # Image built from https://github.com/londonaicentre/flip-omop-db + # Image built from trust/omop-db in this repo (CI-published, vocab-free — + # the licensed vocabulary is loaded at seed time; see trust/omop-db/README.md). + # Tracks DOCKER_TAG like every other repo-built service in this file. The + # OMOP_DB_TAG opt-out pins omop-db at a known-good tag while the rest of the + # stack runs a feature-branch tag — omop-db changes rarely, so its branch tag is + # the one most likely to be missing (branch builds are workflow_dispatch only). + # Same role XNAT_TAG plays for the XNAT images (trust/xnat/Makefile). omop-db: - image: ghcr.io/londonaicentre/omop-db:latest + image: ghcr.io/londonaicentre/omop-db:${OMOP_DB_TAG:-${DOCKER_TAG}} restart: always ports: - "${OMOP_DB_PORT}:5432" diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index 2935d9d21..1a62ac6a8 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -17,9 +17,15 @@ services: # --- Application services --- - # Image built from https://github.com/londonaicentre/flip-omop-db + # Image built from trust/omop-db in this repo (CI-published, vocab-free — + # the licensed vocabulary is loaded at seed time; see trust/omop-db/README.md). + # Tracks DOCKER_TAG like every other repo-built service in this file. The + # OMOP_DB_TAG opt-out pins omop-db at a known-good tag while the rest of the + # stack runs a feature-branch tag — omop-db changes rarely, so its branch tag is + # the one most likely to be missing (branch builds are workflow_dispatch only). + # Same role XNAT_TAG plays for the XNAT images (trust/xnat/Makefile). omop-db: - image: ghcr.io/londonaicentre/omop-db:latest + image: ghcr.io/londonaicentre/omop-db:${OMOP_DB_TAG:-${DOCKER_TAG}} restart: always ports: - "${OMOP_DB_PORT}:5432" diff --git a/trust/imaging-api/README.md b/trust/imaging-api/README.md index f7b4c9e20..2be6b03b3 100644 --- a/trust/imaging-api/README.md +++ b/trust/imaging-api/README.md @@ -107,7 +107,7 @@ Get import status or count for a project (queries the XNAT PostgreSQL database d ``` project_id: 8ba38209-97f5-41b9-976e-dfe3c5c8dd94 -query: SELECT * FROM omop.radiology_occurrence +query: SELECT * FROM omop.image_occurrence ``` ### Upload diff --git a/trust/omop-db/.data_version b/trust/omop-db/.data_version index 906124bb7..c7b4ba62b 100644 --- a/trust/omop-db/.data_version +++ b/trust/omop-db/.data_version @@ -1 +1 @@ -20260129 +20260729 diff --git a/trust/omop-db/.dockerignore b/trust/omop-db/.dockerignore new file mode 100644 index 000000000..d851318ee --- /dev/null +++ b/trust/omop-db/.dockerignore @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The image is vocab-free: no data/ content ever enters the build context. +.venv/ +volumes/ +data/ +dist/ +tests/ +htmlcov-unit/ +.env.build diff --git a/trust/omop-db/.env.build.example b/trust/omop-db/.env.build.example new file mode 100644 index 000000000..4427c3e41 --- /dev/null +++ b/trust/omop-db/.env.build.example @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Config for the standalone image build + populate pipeline (compose.yml). +# Copy to .env.build (gitignored) and adjust. These are LOCAL BUILD credentials +# for the throwaway build databases — the runtime trust stack takes its values +# from the per-trust kit files (trust/.env..) instead. +OMOP_POSTGRES_USER=postgres +OMOP_POSTGRES_PASSWORD=omop +OMOP_POSTGRES_DB=trustomopdb +DATA_ACCESS_POSTGRES_PASSWORD=change_me_analyst_secure_password +OMOP_DB_PORT_TRUST_1=5434 +OMOP_DB_PORT_TRUST_2=5436 + +# Only needed for the optional pgadmin profile. +PGADMIN_EMAIL=admin@example.com +PGADMIN_PASSWORD=admin +PG_ADMIN_PORT=5050 diff --git a/trust/omop-db/.gitignore b/trust/omop-db/.gitignore index 345201e82..4fa29f12a 100644 --- a/trust/omop-db/.gitignore +++ b/trust/omop-db/.gitignore @@ -12,3 +12,11 @@ volumes/*/db_data volumes/.local_data_version* + +# Build-pipeline inputs — fetched, never tracked (the vocab bundles contain +# licensed OMOP vocabularies; the canonical CSVs live on Hugging Face). +data/ +.env.build + +# Exported pgdata tarballs (publish artefacts) +dist/ diff --git a/trust/omop-db/.python-version b/trust/omop-db/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/trust/omop-db/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/trust/omop-db/AGENTS.md b/trust/omop-db/AGENTS.md new file mode 100644 index 000000000..642f6a407 --- /dev/null +++ b/trust/omop-db/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md — trust/omop-db + +## What this directory is + +Two halves of one pipeline (merged from the retired private `flip-omop-db` repo, FLIP#834): + +1. **Image build source** for `ghcr.io/londonaicentre/omop-db`: `Dockerfile` on `postgres:17` bakes the + `files/` init chain (create `omop` schema → OMOP CDM 5.4 DDL → primary keys → indices → read-only + roles) plus the seed-time helpers (`load_core_vocab.sh`, `constraints.sql`, and `unzip` — the + k8s vocab-load Job unpacks the bundle with the image's own copy so it installs nothing at + run time, keeping S3 the only host it must reach). The image is + **vocab-free** (FLIP#842) — nothing licensed in any layer — so it is published by CI + (`docker_build_omop_db.yml`, gated on the "Trust - OMOP DB CI" test workflow like the other + services). FK **constraints are deliberately absent from init** — they are applied only AFTER data + load (loading after constraints fails). +2. **Consumer harness** for the dev trust stacks: `update_omop_data.sh` downloads ready-populated, + **vocab-free** pgdata volumes (~11 MB each, versioned by `.data_version`) from the public HF dataset + `aicentreflip/trust-data` into `volumes/Trust_/db_data`, which + `trust/deploy/compose_trust..yml` mounts. + +`compose.yml` here is the **standalone build/populate stack** (one empty DB per trust + opt-in pgadmin +profile; config from gitignored `.env.build`), NOT the runtime trust stack. + +## The vocabulary seeding model (FLIP#842/#843) + +No published artifact (image, pgdata tarball, HF dataset) carries the licensed core vocabulary. Every +environment loads it ONCE into the running database via `files/load_core_vocab.sh` (client-side +`COPY FROM STDIN` over TCP — no mounts, no server-side files; idempotent via core-aware guards that +tolerate the DICOM vocab already present in the tarballs): + +- **Dev**: `make load-omop-vocab [OMOP_DB_PORT=5436]` (after `update-omop-data` + stack up). Cohort + queries joining `omop.concept` return nothing until this runs. +- **EC2**: the "load OMOP core vocabulary on Trust EC2" Ansible play (part of `seed-trust-data`; + throwaway container on loopback port 15499; kit credentials passed by the AWS Makefile). +- **Kubernetes**: the chart's `omop-vocab-load` post-install/post-upgrade hook Job + (`omopDb.vocabLoad` values; bundle from S3, loader + constraints from the image). Its first + stage runs `load_core_vocab.sh --check` and skips the multi-GB fetch when the database is + already loaded — the hook sits on the critical path of every `helm upgrade`, so the fetch + must stay conditional. The loader still runs (constraints). + +## Load-bearing facts + +- **`.data_version` must not move**: its path is hardcoded in `deploy/providers/AWS/Makefile`, which + passes the value on to Ansible (`-e omop_data_version=`); the Helm chart consumes it via the + `OMOP_DATA_VERSION` env var in `generate_values.py`. +- **Vocabulary licensing**: the core vocab bundle — an OHDSI Athena export, 59 vocabularies incl. + SNOMED CT, LOINC, Read, dm+d (roster + versions in README "The core vocabulary bundle") — is licensed + material: `data/` is gitignored and must never be committed or published. Acquisition: org members via + `make fetch-vocab-core` from `s3://$(VOCAB_S3_BUCKET)/vocab/` (default `flipdev-aicentre`, org AWS + needed); external users self-serve an equivalent export from OHDSI Athena under their own licences. + The DICOM vocab (byte-identical to DICOM2OMOP `files/OMOP CDM Staging/` @ upstream `1ef3354`, Apache + 2.0, pickle converted to CSV) is freely redistributable: it lives on the HF dataset and stays inside + the published tarballs. +- **Read-only roles are a security boundary**: `files/create_readonly_users.sql` creates + `omop_readonly_base` + `data_analyst_reader` (SELECT-only, explicit REVOKEs) — the database half of + data-access-api's SQL-injection defence-in-depth (`data_access_api/services/cohort.py`). The analyst + password is NOT in the image — it is set at first init from `DATA_ACCESS_POSTGRES_PASSWORD` and lives + in the pgdata volume; rotate via `ALTER ROLE` + kit update, or rebuild volumes with a new `.env.build` + value (see CONTRIBUTING.md). +- **Canonical dataset + N-trust split** (`src/omop_db_tools/dataset.py`): mock rows are ONE dataset on + HF (`omop-csv//`), each row tagged `source_trust`. Partition modes: `legacy` (default — + reproduces the original two-trust membership; REQUIRED for data consistent with the published mock + Orthanc PACS volumes, whose studies match each trust's accession IDs) and `modulo` + (`person_id % N`, any trust count, needs regenerated imaging data). All tables carry `person_id`, so + person-level partitioning preserves referential integrity. +- The populate scripts run on the **host** against published ports (`OMOP_DB_HOST` defaults to + localhost) and need postgresql-client (`psql`/`pg_isready`). + +## Commands + +```bash +make update-omop-data [TRUST=1|2] # consumer path: sync vocab-free pgdata volumes from HF +make load-omop-vocab [OMOP_DB_PORT=5436] # seed the licensed vocab + constraints into a running trust DB +cp .env.build.example .env.build # once, before any build-pipeline target +make build # plain docker build — no data inputs, no credentials +make up-build / down-build # the standalone per-trust build DBs +make populate [NUM_TRUSTS=N PARTITION=modulo] # core vocab + DICOM vocab + N trust slices (shipped + # stack is two-trust; N>2 needs a compose service + port) +make populate CORE_VOCAB=0 # vocab-free flavour for publishable tarballs (skip apply-constraints!) +make export-pgdata # tar each volume -> dist/trust_pgdata_<.data_version>.tar +make apply-constraints # AFTER a full populate +make push [OMOP_DB_TAG=...] # manual publish escape hatch (CI publishes normally); confirms first +make local_test # ruff + mypy + pytest tests/unit (no DB needed) +``` + +## Conventions + +- uv project `omop-db-tools` (`src/omop_db_tools/` layout); registered in root `Makefile` `UV_PROJECTS` + and the `uv-lock` pre-commit hooks. Tests live in `tests/unit/` only — anything touching a real + Postgres belongs in `tests/integration/` (none yet). +- SQL identifiers interpolated into statements must pass `import_tables.validate_identifier`. +- The vocab/dataset bundles under `data/`, exported tarballs under `dist/`, and the build env + (`.env.build`) are gitignored — keep it that way. diff --git a/trust/omop-db/CLAUDE.md b/trust/omop-db/CLAUDE.md new file mode 100644 index 000000000..721315269 --- /dev/null +++ b/trust/omop-db/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md — trust/omop-db + +## What this directory is + +Two halves of one pipeline (merged from the retired private `flip-omop-db` repo, FLIP#834): + +1. **Image build source** for `ghcr.io/londonaicentre/omop-db`: `Dockerfile` on `postgres:17` bakes the + `files/` init chain (create `omop` schema → OMOP CDM 5.4 DDL → primary keys → indices → read-only + roles) plus the seed-time helpers (`load_core_vocab.sh`, `constraints.sql`, and `unzip` — the + k8s vocab-load Job unpacks the bundle with the image's own copy so it installs nothing at + run time, keeping S3 the only host it must reach). The image is + **vocab-free** (FLIP#842) — nothing licensed in any layer — so it is published by CI + (`docker_build_omop_db.yml`, gated on the "Trust - OMOP DB CI" test workflow like the other + services). FK **constraints are deliberately absent from init** — they are applied only AFTER data + load (loading after constraints fails). +2. **Consumer harness** for the dev trust stacks: `update_omop_data.sh` downloads ready-populated, + **vocab-free** pgdata volumes (~11 MB each, versioned by `.data_version`) from the public HF dataset + `aicentreflip/trust-data` into `volumes/Trust_/db_data`, which + `trust/deploy/compose_trust..yml` mounts. + +`compose.yml` here is the **standalone build/populate stack** (one empty DB per trust + opt-in pgadmin +profile; config from gitignored `.env.build`), NOT the runtime trust stack. + +## The vocabulary seeding model (FLIP#842/#843) + +No published artifact (image, pgdata tarball, HF dataset) carries the licensed core vocabulary. Every +environment loads it ONCE into the running database via `files/load_core_vocab.sh` (client-side +`COPY FROM STDIN` over TCP — no mounts, no server-side files; idempotent via core-aware guards that +tolerate the DICOM vocab already present in the tarballs): + +- **Dev**: `make load-omop-vocab [OMOP_DB_PORT=5436]` (after `update-omop-data` + stack up). Cohort + queries joining `omop.concept` return nothing until this runs. +- **EC2**: the "load OMOP core vocabulary on Trust EC2" Ansible play (part of `seed-trust-data`; + throwaway container on loopback port 15499; kit credentials passed by the AWS Makefile). +- **Kubernetes**: the chart's `omop-vocab-load` post-install/post-upgrade hook Job + (`omopDb.vocabLoad` values; bundle from S3, loader + constraints from the image). Its first + stage runs `load_core_vocab.sh --check` and skips the multi-GB fetch when the database is + already loaded — the hook sits on the critical path of every `helm upgrade`, so the fetch + must stay conditional. The loader still runs (constraints). + +## Load-bearing facts + +- **`.data_version` must not move**: its path is hardcoded in `deploy/providers/AWS/Makefile`, which + passes the value on to Ansible (`-e omop_data_version=`); the Helm chart consumes it via the + `OMOP_DATA_VERSION` env var in `generate_values.py`. +- **Vocabulary licensing**: the core vocab bundle — an OHDSI Athena export, 59 vocabularies incl. + SNOMED CT, LOINC, Read, dm+d (roster + versions in README "The core vocabulary bundle") — is licensed + material: `data/` is gitignored and must never be committed or published. Acquisition: org members via + `make fetch-vocab-core` from `s3://$(VOCAB_S3_BUCKET)/vocab/` (default `flipdev-aicentre`, org AWS + needed); external users self-serve an equivalent export from OHDSI Athena under their own licences. + The DICOM vocab (byte-identical to DICOM2OMOP `files/OMOP CDM Staging/` @ upstream `1ef3354`, Apache + 2.0, pickle converted to CSV) is freely redistributable: it lives on the HF dataset and stays inside + the published tarballs. +- **Read-only roles are a security boundary**: `files/create_readonly_users.sql` creates + `omop_readonly_base` + `data_analyst_reader` (SELECT-only, explicit REVOKEs) — the database half of + data-access-api's SQL-injection defence-in-depth (`data_access_api/services/cohort.py`). The analyst + password is NOT in the image — it is set at first init from `DATA_ACCESS_POSTGRES_PASSWORD` and lives + in the pgdata volume; rotate via `ALTER ROLE` + kit update, or rebuild volumes with a new `.env.build` + value (see CONTRIBUTING.md). +- **Canonical dataset + N-trust split** (`src/omop_db_tools/dataset.py`): mock rows are ONE dataset on + HF (`omop-csv//`), each row tagged `source_trust`. Partition modes: `legacy` (default — + reproduces the original two-trust membership; REQUIRED for data consistent with the published mock + Orthanc PACS volumes, whose studies match each trust's accession IDs) and `modulo` + (`person_id % N`, any trust count, needs regenerated imaging data). All tables carry `person_id`, so + person-level partitioning preserves referential integrity. +- The populate scripts run on the **host** against published ports (`OMOP_DB_HOST` defaults to + localhost) and need postgresql-client (`psql`/`pg_isready`). + +## Commands + +```bash +make update-omop-data [TRUST=1|2] # consumer path: sync vocab-free pgdata volumes from HF +make load-omop-vocab [OMOP_DB_PORT=5436] # seed the licensed vocab + constraints into a running trust DB +cp .env.build.example .env.build # once, before any build-pipeline target +make build # plain docker build — no data inputs, no credentials +make up-build / down-build # the standalone per-trust build DBs +make populate [NUM_TRUSTS=N PARTITION=modulo] # core vocab + DICOM vocab + N trust slices (shipped + # stack is two-trust; N>2 needs a compose service + port) +make populate CORE_VOCAB=0 # vocab-free flavour for publishable tarballs (skip apply-constraints!) +make export-pgdata # tar each volume -> dist/trust_pgdata_<.data_version>.tar +make apply-constraints # AFTER a full populate +make push [OMOP_DB_TAG=...] # manual publish escape hatch (CI publishes normally); confirms first +make local_test # ruff + mypy + pytest tests/unit (no DB needed) +``` + +## Conventions + +- uv project `omop-db-tools` (`src/omop_db_tools/` layout); registered in root `Makefile` `UV_PROJECTS` + and the `uv-lock` pre-commit hooks. Tests live in `tests/unit/` only — anything touching a real + Postgres belongs in `tests/integration/` (none yet). +- SQL identifiers interpolated into statements must pass `import_tables.validate_identifier`. +- The vocab/dataset bundles under `data/`, exported tarballs under `dist/`, and the build env + (`.env.build`) are gitignored — keep it that way. diff --git a/trust/omop-db/CONTRIBUTING.md b/trust/omop-db/CONTRIBUTING.md index 74db25197..c56a62800 100644 --- a/trust/omop-db/CONTRIBUTING.md +++ b/trust/omop-db/CONTRIBUTING.md @@ -18,45 +18,36 @@ For general contribution guidelines (coding style, testing, pull requests), see ## Local development -### Running tests locally - -Ensure the database is running before executing tests: - -```bash -cd ../.. -make up-trusts # or just: docker compose up omop-db -d -``` - -Then in this directory: +The populate tooling is a small uv project (`src/omop_db_tools/`); its unit +tests need no database: ```bash uv sync -uv run ruff check . --fix -uv run pytest --tb=short --disable-warnings --cov=app/ --cov-report=html --cov-report=term-missing +make local_test # ruff + mypy + pytest tests/unit ``` -Or use the shorthand: - -```bash -make test -``` +Exercising the full build → populate → constraints pipeline against real +databases is described in the [README](README.md) ("Building the image"). ## Connecting pgAdmin to the OMOP database -If pgAdmin is running on a remote machine, tunnel the port first: +pgAdmin ships as an opt-in profile of the build compose stack +(`docker compose -f compose.yml --env-file .env.build --profile pgadmin up -d` +— the `--env-file` matters: compose does not auto-load `.env.build`). If it is +running on a remote machine, tunnel the port first: ```bash ssh -L 5050:localhost:5050 ``` 1. Open pgAdmin at and log in. Credentials are `PGADMIN_EMAIL` and `PGADMIN_PASSWORD` from - [`.env.development.example`](../../.env.development.example). + your `.env.build` (template: [`.env.build.example`](.env.build.example)). 2. Click **Register Server** and configure: - **General > Name**: `trust` (or any label) - - **Connection > Host**: `omop-db` + - **Connection > Host**: `omop-db-trust1` (or `omop-db-trust2`) - **Connection > Port**: `5432` - - **Connection > Username**: `OMOP_POSTGRES_USER` from `.env.development` - - **Connection > Password**: `OMOP_POSTGRES_PASSWORD` from `.env.development` + - **Connection > Username**: `OMOP_POSTGRES_USER` from `.env.build` + - **Connection > Password**: `OMOP_POSTGRES_PASSWORD` from `.env.build` - Toggle **Save password** To inspect data: right-click a table (e.g. `image_occurrence`) → **Scripts** → **SELECT Script**, then execute. @@ -65,21 +56,34 @@ To inspect data: right-click a table (e.g. `image_occurrence`) → **Scripts** ## Developer notes -### Accession ID encryption +### Read-only roles -The encrypt service of the data-import-api is not currently used to encrypt accession IDs before they are stored in -the OMOP database. This is a known limitation being tracked. +`files/create_readonly_users.sql` (run at image init via +`create_readonly_users.sh`) creates the `omop_readonly_base` role and the +`data_analyst_reader` login that `data-access-api` uses for cohort queries — +its SELECT-only grants are the database half of that API's SQL-injection +defence-in-depth (see +`trust/data-access-api/data_access_api/services/cohort.py`). -### SQL initialization — service account +### Rotating the data analyst password -The following SQL was added to the initialization script (exported via pgAdmin's Backup function) to ensure the -`serviceaccount` user is created correctly: +The `data_analyst_reader` password is **not baked into the image**: the init +hook reads `DATA_ACCESS_POSTGRES_PASSWORD` from the container environment at +*first* init and stores it in the database cluster — i.e. it lives in the +pgdata volume (and therefore in the published pgdata tarballs, which carry +whatever value they were initialised with). Two rotation paths: -```sql --- Create service account user for OMOP database --- Replace with the value of OMOP_SERVICE_PASSWORD from .env.development -CREATE USER serviceaccount WITH ENCRYPTED PASSWORD ''; +- **Live database** (no rebuild): + ``ALTER ROLE data_analyst_reader WITH PASSWORD '';`` then update + `DATA_ACCESS_POSTGRES_PASSWORD` in the trust kit file so data-access-api + matches. +- **Fresh volumes**: set the new value in `.env.build` and re-run the build → + populate pipeline (see the README). If the resulting volumes are published as + new pgdata tarballs, consuming kit files must be updated to the matching + password. -GRANT USAGE ON SCHEMA omop TO serviceaccount; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA omop TO serviceaccount; -``` +### Accession ID encryption + +Accession IDs are currently stored unencrypted in the OMOP database (the mock +data uses synthetic `FAK`-prefixed IDs). Encrypting them at rest is a known +limitation. diff --git a/trust/omop-db/Dockerfile b/trust/omop-db/Dockerfile new file mode 100644 index 000000000..cf313ac17 --- /dev/null +++ b/trust/omop-db/Dockerfile @@ -0,0 +1,57 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Builds the base omop postgres image: OMOP CDM 5.4 schema, indices and +# read-only roles only. The image is deliberately vocab-free — the licensed +# vocabulary bundle is streamed into a running database by +# /flip/omop/load_core_vocab.sh (one seeding step per environment; see +# README "Populating"), so every published artifact stays licence-clean and +# the image is CI-buildable. POSTGRES_* are runtime environment (set by the +# compose file) — initdb runs at first container start, not at build, so +# nothing credential-shaped is baked in. +FROM postgres:17 + +# The source label associates the GHCR package with this repo. +LABEL org.opencontainers.image.source="https://github.com/londonaicentre/FLIP" \ + org.opencontainers.image.description="FLIP trust OMOP database (OMOP CDM 5.4 + MI-CDM, schema and roles baked, vocabulary loaded at seed time; see trust/omop-db/)" \ + org.opencontainers.image.licenses="Apache-2.0" + +# Copy initialization scripts (Schema DDL, Keys, Indices, Users) +COPY ./files/init.sql /docker-entrypoint-initdb.d/00_init.sql +COPY ./files/OMOPCDM_postgresql_5.4_ddl.sql /docker-entrypoint-initdb.d/10_ddl.sql +COPY ./files/OMOPCDM_postgresql_5.4_primary_keys.sql /docker-entrypoint-initdb.d/20_primary_keys.sql +COPY ./files/OMOPCDM_postgresql_5.4_indices.sql /docker-entrypoint-initdb.d/30_indices.sql +# The SQL is parameterized; a shell wrapper reads DATA_ACCESS_POSTGRES_PASSWORD +# from the runtime environment and invokes psql with -v. +COPY ./files/create_readonly_users.sql /flip/omop/create_readonly_users.sql +COPY ./files/create_readonly_users.sh /docker-entrypoint-initdb.d/50_create_readonly_users.sh +RUN chmod +x /docker-entrypoint-initdb.d/50_create_readonly_users.sh + +# The vocabulary bundle ships as a zip and the postgres base has no extractor +# (it does carry tar and gzip). Installing unzip at build time rather than +# letting the Kubernetes vocab-load Job install it at runtime is what keeps that +# Job free of any dependency on a package mirror: a trust behind an egress +# allowlist can reach its S3 bucket but not necessarily Amazon Linux's repos. +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip \ + && rm -rf /var/lib/apt/lists/* + +# Seed-time helpers, shipped in the image so any environment (dev host, EC2, +# k8s job) can run them with the image's own psql: +# - load_core_vocab.sh streams a vocabulary bundle into a RUNNING database +# - constraints.sql is applied AFTER data load (importing after constraints +# fails), by the loader or `make apply-constraints` +COPY ./files/load_core_vocab.sh /flip/omop/load_core_vocab.sh +RUN chmod +x /flip/omop/load_core_vocab.sh +COPY ./files/OMOPCDM_postgresql_5.4_constraints.sql /flip/omop/constraints.sql + +EXPOSE 5432 diff --git a/trust/omop-db/Makefile b/trust/omop-db/Makefile index a3c4b7776..b8a496c7d 100644 --- a/trust/omop-db/Makefile +++ b/trust/omop-db/Makefile @@ -18,14 +18,27 @@ include $(KIT_ENV_FILE) export $(shell sed 's/=.*//' $(KIT_ENV_FILE)) endif +# Build-pipeline config (gitignored; cp .env.build.example .env.build). Included +# after the kit example so build values win inside the build pipeline. +BUILD_ENV_FILE=.env.build +ifneq ("$(wildcard $(BUILD_ENV_FILE))","") +include $(BUILD_ENV_FILE) +export $(shell sed 's/=.*//' $(BUILD_ENV_FILE)) +endif + network1 = deploy_trust-network-1 -# network2 = deploy_trust-network-2 service_name = omop-db docker_command = docker compose --project-directory .. -f ../deploy/compose_trust.development.yml -run_clean = run --rm --remove-orphans -# Using the run command ignores the port mapping +lint_command = uv run ruff check . --fix +mypy_command = uv run mypy src/ tests/ --ignore-missing-imports +test_coverage_command = uv run pytest tests/unit --tb=short --disable-warnings --cov=src/omop_db_tools --cov-report=html:htmlcov-unit --cov-report=term-missing --cov-report=xml:coverage-unit.xml + +# ───────────────────────────────────────────────────────────────────────────── +# Runtime-stack helpers (the omop-db service inside a trust stack) +# ───────────────────────────────────────────────────────────────────────────── + up-test-omop-trust1: # This will just be used for testing BASE_IMAGES_DOWNLOAD_DIR=${BASE_IMAGES_DOWNLOAD_DIR} \ @@ -50,3 +63,205 @@ shell: # Pass TRUST=1 or TRUST=2 to update only a single trust; defaults to all. update-omop-data: TRUST=$(TRUST) CLEAN_AFTER_UPDATE=True ./update_omop_data.sh + +# Load the licensed core vocabulary into a RUNNING trust omop-db and apply the +# FK constraints. The published pgdata tarballs are vocab-free, so after +# `make update-omop-data` + starting the stack this runs once per trust — the +# one credentialed seeding step (see README "The core vocabulary bundle"). +# Port defaults to the GSTT kit's OMOP_DB_PORT; override for other trusts, +# e.g. `make load-omop-vocab OMOP_DB_PORT=5436` (KCH). +load-omop-vocab: fetch-vocab-core + OMOP_DB_PORT=$(OMOP_DB_PORT) ./files/load_core_vocab.sh \ + data/$(VOCAB_CORE_BUNDLE) files/OMOPCDM_postgresql_5.4_constraints.sql + +# ───────────────────────────────────────────────────────────────────────────── +# Image build + populate pipeline (standalone; see README "Building the image") +# ───────────────────────────────────────────────────────────────────────────── + +build_compose = docker compose -f compose.yml +GHCR_IMAGE = ghcr.io/londonaicentre/omop-db +OMOP_DB_TAG ?= latest +# S3 bucket holding the vocab bundle zips under vocab/ (the AI Centre's dev +# bucket by default; needs AWS credentials for that account). Contributors +# without bucket access self-serve from OHDSI Athena — see README +# "The core vocabulary bundle". +VOCAB_S3_BUCKET ?= flipdev-aicentre + +# Fetched inputs (all gitignored under data/). The core vocab bundle contains +# licensed OMOP vocabularies (SNOMED CT, LOINC, Read, ...) and must never be +# committed; the DICOM bundle and the canonical CSV dataset are public. +# NOTE when bumping VOCAB_CORE_BUNDLE, update the same bundle name pinned in +# the Ansible vocab-load play (deploy/providers/AWS/site.yml) and the Helm +# omopDb.vocabLoad.bundleName default — the Dockerfile needs no change (the +# image is vocab-free). +VOCAB_CORE_BUNDLE = vocab_aicentre_core_20240916 +VOCAB_DICOM_BUNDLE = vocab_dicom_paulnagy_20260109 +OMOP_CSV_DATA_VERSION = 20260729 +HF_TRUST_DATA_BASE = https://huggingface.co/datasets/aicentreflip/trust-data/resolve/main + +# The shipped build stack is two-trust: compose.yml defines omop-db-trust1/2 and +# .env.build carries their ports. For NUM_TRUSTS>2, add an omop-db-trust +# service and an OMOP_DB_PORT_TRUST_ — populate-trust- fails fast otherwise. +NUM_TRUSTS = 2 +PARTITION = legacy +# CORE_VOCAB=0 skips the licensed core-vocabulary load during populate — used +# when producing the vocab-free pgdata tarballs published to Hugging Face +# (constraints must then also be skipped: they reference the vocab tables). +CORE_VOCAB = 1 + +check-build-env: + @if [ ! -f "$(BUILD_ENV_FILE)" ]; then \ + echo "❌ $(BUILD_ENV_FILE) not found — run: cp .env.build.example .env.build"; \ + exit 1; \ + fi + +# The image is vocab-free — building needs no data/ inputs at all. +build: + $(build_compose) build + +up-build: check-build-env build + $(build_compose) up -d omop-db-trust1 omop-db-trust2 + +down-build: + $(build_compose) down --remove-orphans + +# Primary source for the core vocab: the org S3 bucket (same bundle the private +# repo used; see README "The core vocabulary bundle" for its contents). All +# stages land in temp paths and rename on success, so a failed/interrupted +# download or extraction is never mistaken for a present bundle on the next run. +fetch-vocab-core: + @if [ -d "data/$(VOCAB_CORE_BUNDLE)" ]; then \ + echo "data/$(VOCAB_CORE_BUNDLE) already present — skipping."; \ + else \ + if ! command -v aws > /dev/null 2>&1 || ! aws sts get-caller-identity > /dev/null 2>&1; then \ + echo "❌ AWS CLI access unavailable (needed for s3://$(VOCAB_S3_BUCKET)/vocab/)."; \ + echo " Without bucket access, self-serve the bundle from OHDSI Athena — see README."; \ + exit 1; \ + fi; \ + mkdir -p data; \ + echo "⬇️ Downloading s3://$(VOCAB_S3_BUCKET)/vocab/$(VOCAB_CORE_BUNDLE).zip ..."; \ + if ! aws s3 cp "s3://$(VOCAB_S3_BUCKET)/vocab/$(VOCAB_CORE_BUNDLE).zip" "data/$(VOCAB_CORE_BUNDLE).zip.tmp"; then \ + rm -f "data/$(VOCAB_CORE_BUNDLE).zip.tmp"; \ + echo "❌ download failed — no core vocab bundle fetched"; \ + exit 1; \ + fi; \ + mv "data/$(VOCAB_CORE_BUNDLE).zip.tmp" "data/$(VOCAB_CORE_BUNDLE).zip"; \ + rm -rf "data/.$(VOCAB_CORE_BUNDLE).tmp"; \ + if ! unzip -q "data/$(VOCAB_CORE_BUNDLE).zip" -d "data/.$(VOCAB_CORE_BUNDLE).tmp"; then \ + rm -rf "data/.$(VOCAB_CORE_BUNDLE).tmp"; \ + echo "❌ unzip failed — delete data/$(VOCAB_CORE_BUNDLE).zip and re-run"; \ + exit 1; \ + fi; \ + mv "data/.$(VOCAB_CORE_BUNDLE).tmp" "data/$(VOCAB_CORE_BUNDLE)"; \ + fi + +# The DICOM vocab (NEMA PS3, via DICOM2OMOP — Apache 2.0) is freely +# redistributable and fetched anonymously from the public HF dataset. +# Downloaded to a temp file and renamed on success, so a partial download is +# never mistaken for a present bundle on the next run. +fetch-vocab-dicom: + @if [ -d "data/$(VOCAB_DICOM_BUNDLE)" ] || [ -f "data/$(VOCAB_DICOM_BUNDLE).zip" ]; then \ + echo "data/$(VOCAB_DICOM_BUNDLE) already present — skipping."; \ + else \ + mkdir -p data; \ + if ! curl -fSL -o "data/$(VOCAB_DICOM_BUNDLE).zip.tmp" "$(HF_TRUST_DATA_BASE)/omop-vocab/$(VOCAB_DICOM_BUNDLE).zip"; then \ + rm -f "data/$(VOCAB_DICOM_BUNDLE).zip.tmp"; \ + echo "❌ download failed — no DICOM vocab bundle fetched"; \ + exit 1; \ + fi; \ + mv "data/$(VOCAB_DICOM_BUNDLE).zip.tmp" "data/$(VOCAB_DICOM_BUNDLE).zip"; \ + fi + +fetch-dataset: + uv run python -m omop_db_tools.dataset fetch --version $(OMOP_CSV_DATA_VERSION) --dest data/canonical + +# Every fan-out below is `$(addprefix …,$(shell seq 1 $(NUM_TRUSTS)))`. `$(shell)` +# is expanded at parse time and make never fails on its exit status, so a +# NUM_TRUSTS of 0 — or a typo like `two` — yields ZERO prerequisites and the +# target "succeeds" having done nothing at all. That is indistinguishable from a +# real run to any wrapper script checking the exit code, and `make populate` +# would go on to fetch multi-GB bundles and load none of them. Guard first. +check-num-trusts: + @case "$(NUM_TRUSTS)" in \ + ''|*[!0-9]*) \ + echo "❌ NUM_TRUSTS must be a positive integer (got '$(NUM_TRUSTS)')"; \ + exit 1 ;; \ + esac; \ + if [ "$(NUM_TRUSTS)" -le 0 ]; then \ + echo "❌ NUM_TRUSTS must be a positive integer (got '$(NUM_TRUSTS)')"; \ + exit 1; \ + fi + +# Populate every build database with its slice of the canonical dataset, then +# `make apply-constraints`. Constraints are deliberately NOT part of image init: +# loading data after applying them fails. +populate: check-num-trusts fetch-vocab-dicom fetch-dataset $(addprefix populate-trust-,$(shell seq 1 $(NUM_TRUSTS))) + +populate-trust-%: check-build-env + @if [ -z "$(OMOP_DB_PORT_TRUST_$*)" ]; then \ + echo "❌ OMOP_DB_PORT_TRUST_$* is not set — add it to $(BUILD_ENV_FILE) (and an omop-db-trust$* service to compose.yml)"; \ + exit 1; \ + fi + ./wait-for-postgres.sh $(OMOP_DB_PORT_TRUST_$*) + @if [ "$(CORE_VOCAB)" = "1" ]; then \ + $(MAKE) fetch-vocab-core && \ + OMOP_DB_PORT=$(OMOP_DB_PORT_TRUST_$*) ./files/load_core_vocab.sh data/$(VOCAB_CORE_BUNDLE); \ + else \ + echo "⏭️ CORE_VOCAB=0 — skipping licensed core-vocabulary load (vocab-free export flavour)."; \ + fi + OMOP_DB_PORT=$(OMOP_DB_PORT_TRUST_$*) uv run python -m omop_db_tools.load_dicom_vocab \ + --vocab-dir data/$(VOCAB_DICOM_BUNDLE) + OMOP_DB_PORT=$(OMOP_DB_PORT_TRUST_$*) uv run python -m omop_db_tools.import_tables \ + --trust-index $* --num-trusts $(NUM_TRUSTS) --partition $(PARTITION) --data-dir data/canonical + +apply-constraints: check-num-trusts check-build-env $(addprefix apply-constraints-trust-,$(shell seq 1 $(NUM_TRUSTS))) + +apply-constraints-trust-%: + @echo "🔗 Applying OMOP CDM constraints (trust $*)..." + $(build_compose) exec -T omop-db-trust$* psql -v ON_ERROR_STOP=1 -U $(OMOP_POSTGRES_USER) -d $(OMOP_POSTGRES_DB) -f /flip/omop/constraints.sql + +# Produce the publishable vocab-free pgdata tarballs (README "Populating"): +# run the pipeline with `make populate CORE_VOCAB=0` (and WITHOUT +# apply-constraints — the FKs reference the absent vocab), then export each +# populated volume. Tar runs in a root container because the pgdata trees are +# uid-999-owned; the archive root is the db_data CONTENTS, matching what +# update_omop_data.sh extracts. +PGDATA_VERSION = $(shell tr -d ' \n\r\t' < .data_version) + +export-pgdata: check-num-trusts $(addprefix export-pgdata-trust-,$(shell seq 1 $(NUM_TRUSTS))) + +export-pgdata-trust-%: + @mkdir -p dist + docker run --rm -v "$$(pwd)/volumes/Trust_$*/db_data:/d:ro" -v "$$(pwd)/dist:/out" alpine \ + tar -czf "/out/trust$*_pgdata_$(PGDATA_VERSION).tar" -C /d . + @echo "📦 dist/trust$*_pgdata_$(PGDATA_VERSION).tar" + +# Push the already-built image to GHCR. Do `docker login ghcr.io` first if +# needed. Interactive confirmation: OMOP_DB_TAG defaults to :latest here, which +# CI otherwise publishes only from main (alongside :prod), so an unqualified +# push silently repoints the "newest release" pointer at a local build. No +# deployment path resolves :latest any more — the trust composes follow +# DOCKER_TAG, the Helm chart and the Ansible vocab-load play default to stag. +push: + @printf "About to push %s — continue? [y/N] " "$(GHCR_IMAGE):$(OMOP_DB_TAG)"; \ + read ans; \ + if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then echo "aborted"; exit 1; fi + docker push $(GHCR_IMAGE):$(OMOP_DB_TAG) + +# ───────────────────────────────────────────────────────────────────────────── +# Lint & tests (the populate tooling has no service container — all local uv) +# ───────────────────────────────────────────────────────────────────────────── + +lint: local_lint +mypy: local_mypy +local_lint: + $(lint_command) +local_mypy: + $(mypy_command) +# tests/test_load_core_vocab.sh is bash, not pytest: it drives files/load_core_vocab.sh +# with a stubbed psql, so it needs no Postgres and stays a unit test. It runs here +# rather than under pytest because pytest only collects tests/unit/. +local_test: + $(lint_command) && $(mypy_command) && $(test_coverage_command) && bash tests/test_load_core_vocab.sh +unit_test: local_test +test: local_test diff --git a/trust/omop-db/README.md b/trust/omop-db/README.md index b3dcc51ae..998676fb2 100644 --- a/trust/omop-db/README.md +++ b/trust/omop-db/README.md @@ -13,9 +13,20 @@ # Trust OMOP database -Postgres database containing OMOP-ified data. +Postgres database containing OMOP-ified data. This directory is both the +**build source** for the `ghcr.io/londonaicentre/omop-db` image (OMOP CDM 5.4 +schema with the MI-CDM imaging extension — `image_occurrence`, +`image_feature` — plus read-only role setup; imported from the retired private +`flip-omop-db` repo, FLIP#834) and the **consumer harness** that downloads +ready-populated data volumes for the dev trust stacks. -## Set up +**Every published artifact is licence-clean (FLIP#842/#843)**: the image and +the pgdata tarballs are *vocab-free* — the licensed core vocabulary is +streamed into each running database as a one-time seeding step +(`files/load_core_vocab.sh`), from a source each environment is licensed to +use. + +## Using the database (dev trust stacks) We have prepared mock data for each of the 2 dev trusts (GSTT and KCH) as postgres data volumes, published to the public Hugging Face dataset [`aicentreflip/trust-data`](https://huggingface.co/datasets/aicentreflip/trust-data). In order to set up the database locally, these data volumes need to be downloaded/extracted. They are fetched anonymously over HTTPS — no AWS CLI or credentials required. This will be handled automatically when creating the trust containers, and similarly they will be updated locally when the desired version changes (note for devs: this is controlled by the `.data_version` file in this directory). @@ -34,10 +45,165 @@ make -C trust up-trust KIT=GSTT # GSTT only make -C trust up-trust KIT=KCH # KCH only ``` +The downloaded volumes are **vocab-free** (~11 MB each): after the stack is +up, load the core vocabulary into each trust database once (idempotent — safe +to re-run; needs the bundle, see "The core vocabulary bundle" below, and +`psql` on the host). Unlike the stack commands above, these run from this +directory — `make -C trust/omop-db …` from the repository root: + +```sh +make -C trust/omop-db load-omop-vocab # Trust_1 (GSTT, port 5434) +make -C trust/omop-db load-omop-vocab OMOP_DB_PORT=5436 # Trust_2 (KCH) +``` + +Cohort queries that join `omop.concept` return nothing until this step has +run. On EC2 trusts the equivalent load is part of `seed-trust-data` (Ansible); +on Kubernetes it is the chart's `omop-vocab-load` post-install job. + +To ask a database whether it has been loaded — without a bundle, and without +loading anything — run the script's probe mode. It exits 0 only when every +vocabulary table already holds core rows, which is how the Kubernetes hook +decides whether it needs to download the bundle at all: + +```sh +cd trust/omop-db && OMOP_DB_PORT=5434 OMOP_POSTGRES_USER=… OMOP_POSTGRES_PASSWORD=… \ + OMOP_POSTGRES_DB=… ./files/load_core_vocab.sh --check +``` + For database-only debugging (without the rest of the trust stack), `make -C trust/omop-db up-test-omop-trust1` will start just the first dev trust's OMOP container. Bringing the container up should not run any initialization scripts — the data volume already contains a populated database. +## Building the image + +The image bakes the schema init chain (`files/`: schema DDL → primary keys → +indices → read-only users) plus the seed-time helpers +(`load_core_vocab.sh`, `constraints.sql`) into `postgres:17` — **no +vocabulary, no data**, so the build needs no credentials and is published by +CI (`docker_build_omop_db.yml`, test-gated like the other services). FK +constraints are deliberately **not** applied at init — data must load first +(see `apply-constraints` below). + +```sh +cp .env.build.example .env.build # local build credentials (gitignored) +make build # plain docker build — no data inputs +``` + +### The core vocabulary bundle + +`vocab_aicentre_core_20240916` is an [OHDSI Athena](https://athena.ohdsi.org/) +vocabulary export (snapshot `v5.0 30-AUG-24`): nine tab-separated files +(~600 MB as the distributed zip, ~3.6 GB unpacked — so a loader that unpacks +beside the zip needs ~4.2 GB of scratch space, which is what sizes the +Kubernetes vocab-load Job's work dir — `CONCEPT.csv` 819 MB, `CONCEPT_RELATIONSHIP.csv` 2.0 GB, +`CONCEPT_ANCESTOR.csv` 704 MB, `CONCEPT_SYNONYM.csv` 139 MB, +`DRUG_STRENGTH.csv` 159 MB, plus the small `CONCEPT_CLASS` / `DOMAIN` / +`RELATIONSHIP` / `VOCABULARY` tables) that +`load_core_vocab.sh` streams into the `omop` schema at seed time. It carries +**59 vocabularies** — the load-bearing ones for +FLIP's cohort queries and the licensing-relevant ones are: + +| Vocabulary | Version in bundle | Licensing | +|---|---|---| +| SNOMED CT | 2024-02 Int / 2024-03 US / 2024-04 UK editions | Affiliate licence (SNOMED International / NHS) | +| LOINC | 2.77 | Regenstrief terms of use | +| Read v2 | NHS READV2 21.0.0 | NHS TRUD licence | +| dm+d | 2023-05-22 | NHS | +| ICD-10 | WHO 2021 release | WHO | +| ICD-9-CM / ICD-10-CM / ICD-10-PCS | v32 / FY2025 / 2024 | Public domain (US) | +| RxNorm / RxNorm Ext / NDC | 20240506 / 20240701 / 20240825 | UMLS terms (RxNorm) | +| OMOP structural vocabularies (Domain, Concept Class, Type Concept, Visit, ...) | — | Apache 2.0 (OHDSI) | + +Because of the licensed entries the bundle is **never tracked in git**, never +part of the published image, and not fetchable in CI. Two ways to obtain it +(not needed to build the image, but needed both to populate fresh datasets and +to run the one-time `make load-omop-vocab` seeding of any stack — see +"Using the database (dev trust stacks)" above; a stack whose vocabulary was never loaded +starts cleanly but returns nothing from cohort queries that join +`omop.concept`): + +1. **FLIP developers (org AWS access)** — `make fetch-vocab-core`: downloads + `s3://flipdev-aicentre/vocab/vocab_aicentre_core_20240916.zip` (override + the bucket with `VOCAB_S3_BUCKET=`) and unpacks it into `data/` — the same + source and technique the private repo used. Each deploy environment reads + from its **own** bucket (`AICENTRE_BUCKET_NAME`; no cross-account access) — + when staging the zip in an env bucket, upload it with `--sse AES256`: the + trust EC2 role has no `kms:Decrypt` on the buckets' default KMS keys. +2. **Anyone, under their own licences** — build an equivalent bundle from + [OHDSI Athena](https://athena.ohdsi.org/): request an export containing the + vocabularies in the roster above (SNOMED CT and LOINC require accepting + their licences on Athena; the UK SNOMED / Read / dm+d editions come via + [NHS TRUD](https://isd.digital.nhs.uk/)), then place the export's CSV files + at `data/vocab_aicentre_core_20240916/`. Concept coverage may differ + slightly from the org snapshot depending on release dates. + +### The DICOM vocabulary bundle + +`vocab_dicom_paulnagy_20260109` (loaded at populate time by +`load_dicom_vocab.py`) is an exact, byte-for-byte copy of four files from the +Apache-2.0 [DICOM2OMOP](https://github.com/paulnagy/DICOM2OMOP) project's +`files/OMOP CDM Staging/` directory as of upstream commit `1ef3354` +(2026-01-08, "Update notebook with new numbering and regenerate tables"): +`omop_table_staging_v5.csv`, `cs_values_maps_to.csv`, +`cs_values_maps_to_value.csv`, and `part3_to_part16_relationship_via_CID` — +the last converted from the upstream pickle to CSV when the bundle was +published to the Hugging Face dataset, so the loader deserialises no pickles. +It is NEMA PS3-derived and freely redistributable; `make fetch-vocab-dicom` +fetches it anonymously. + +## Populating (the canonical dataset and N-trust splitting) + +The synthetic mock rows live as **one canonical CSV dataset** on the public +Hugging Face dataset under `omop-csv//` (version pinned by +`OMOP_CSV_DATA_VERSION` in the Makefile). Every row carries a `source_trust` +provenance column, and standing up N trusts is a deterministic split +(`src/omop_db_tools/dataset.py`): + +- `legacy` (default): partition by `source_trust` — reproduces the original + two-trust membership exactly, keeping each trust's OMOP accession IDs + consistent with that trust's published mock PACS (Orthanc) data. +- `modulo`: partition by `person_id % NUM_TRUSTS` — any trust count, for fresh + stand-ups where the imaging data is regenerated to match. + +```sh +uv sync +make up-build # builds if needed, then starts the two build DBs +make populate # core vocab + DICOM vocab + each trust's dataset slice +make apply-constraints # FK constraints go on AFTER the load +``` + +Populating runs from the host and needs `psql`/`pg_isready` +(postgresql-client). The shipped build stack is **two-trust**: `NUM_TRUSTS` / +`PARTITION` thread through to the split tooling, but standing up more than two +trusts additionally needs an `omop-db-trust` service in `compose.yml` and +an `OMOP_DB_PORT_TRUST_` in `.env.build` — `make populate NUM_TRUSTS=3 +PARTITION=modulo` fails fast until they exist (and `modulo` implies +regenerating the matching imaging data). + +### Publishing new pgdata tarballs + +The published tarballs must be **vocab-free** (they are public): run the +pipeline with the core-vocabulary step skipped and WITHOUT `apply-constraints` +(the FKs reference the absent vocab tables — they are applied at seed time by +the vocab load instead): + +```sh +make up-build && make populate CORE_VOCAB=0 +make export-pgdata # dist/trust_pgdata_<.data_version>.tar +``` + +Upload each archive under `trust/` in the Hugging Face dataset and bump +`.data_version`. The DICOM vocabulary and the synthetic cohort stay in the +tarball (both freely redistributable); the archives are ~11 MB. + +To publish the image manually (CI normally does this): `make push` +(GHCR write access required; `OMOP_DB_TAG` overrides the tag, and the target +asks for confirmation — CI publishes `:latest` only from `main`, so an +unqualified push repoints the "newest release" pointer at a local build). + +The canonical dataset is regenerated from per-trust CSV exports with +`uv run python -m omop_db_tools.dataset build --trust-dirs --dest `. + ## Further Reading - [Trust deployment overview](../README.md) diff --git a/trust/omop-db/compose.yml b/trust/omop-db/compose.yml new file mode 100644 index 000000000..625f58c0b --- /dev/null +++ b/trust/omop-db/compose.yml @@ -0,0 +1,100 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Standalone image build + populate pipeline (NOT the runtime trust stack — that +# is trust/deploy/compose_trust..yml). Brings up one empty OMOP database +# per trust so `make populate` can load each trust's slice of the canonical +# dataset; the resulting volumes/Trust_/db_data trees are what the trust +# stack mounts (and what gets exported to the Hugging Face pgdata tarballs). +# Config comes from .env.build (see .env.build.example). +services: + omop-db-trust1: + image: ${OMOP_DB_IMAGE:-ghcr.io/londonaicentre/omop-db}:${OMOP_DB_TAG:-latest} + build: + context: . + dockerfile: Dockerfile + # Same hardening as the runtime service in trust/deploy/compose_trust.*.yml: + # the retained caps are exactly what the postgres entrypoint needs (chown of + # PGDATA to uid 999 + the gosu privilege drop). In normal operation initdb + # runs only here — runtime stacks mount pre-populated volumes. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETUID + - SETGID + ports: + - "${OMOP_DB_PORT_TRUST_1:-5434}:5432" + environment: + POSTGRES_USER: ${OMOP_POSTGRES_USER} + POSTGRES_PASSWORD: ${OMOP_POSTGRES_PASSWORD} + POSTGRES_DB: ${OMOP_POSTGRES_DB} + DATA_ACCESS_POSTGRES_PASSWORD: ${DATA_ACCESS_POSTGRES_PASSWORD} + volumes: + - ./volumes/Trust_1/db_data:/var/lib/postgresql/data + networks: + - omop-build-network + + omop-db-trust2: + image: ${OMOP_DB_IMAGE:-ghcr.io/londonaicentre/omop-db}:${OMOP_DB_TAG:-latest} + build: + context: . + dockerfile: Dockerfile + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETUID + - SETGID + ports: + - "${OMOP_DB_PORT_TRUST_2:-5436}:5432" + environment: + POSTGRES_USER: ${OMOP_POSTGRES_USER} + POSTGRES_PASSWORD: ${OMOP_POSTGRES_PASSWORD} + POSTGRES_DB: ${OMOP_POSTGRES_DB} + DATA_ACCESS_POSTGRES_PASSWORD: ${DATA_ACCESS_POSTGRES_PASSWORD} + volumes: + - ./volumes/Trust_2/db_data:/var/lib/postgresql/data + networks: + - omop-build-network + + # Optional DB browser; opt in with + # `docker compose -f compose.yml --env-file .env.build --profile pgadmin up -d`. + pgadmin: + image: dpage/pgadmin4:9 + profiles: ["pgadmin"] + security_opt: + - no-new-privileges:true + ports: + - "${PG_ADMIN_PORT:-5050}:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + volumes: + - pgadmin_data:/var/lib/pgadmin + networks: + - omop-build-network + +volumes: + pgadmin_data: + +networks: + omop-build-network: + driver: bridge diff --git a/trust/omop-db/files/OMOPCDM_postgresql_5.4_constraints.sql b/trust/omop-db/files/OMOPCDM_postgresql_5.4_constraints.sql new file mode 100644 index 000000000..3f964ab99 --- /dev/null +++ b/trust/omop-db/files/OMOPCDM_postgresql_5.4_constraints.sql @@ -0,0 +1,727 @@ +-- Portions derived from the OHDSI CommonDataModel project +-- (https://github.com/OHDSI/CommonDataModel, inst/ddl/5.4/postgresql), +-- extended with the MI-CDM medical-imaging tables. +-- Copyright (c) Observational Health Data Sciences and Informatics (OHDSI) +-- Licensed under the Apache License, Version 2.0. +-- SPDX-License-Identifier: Apache-2.0 +-- +-- Modifications Copyright (c) 2026 +-- Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +set schema 'omop'; + +--postgresql CDM Foreign Key Constraints for OMOP Common Data Model 5.4 + +DO $$ +BEGIN + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_person_gender_concept_id') + THEN + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_gender_concept_id FOREIGN KEY (gender_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_race_concept_id FOREIGN KEY (race_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_ethnicity_concept_id FOREIGN KEY (ethnicity_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_location_id FOREIGN KEY (location_id) REFERENCES LOCATION (LOCATION_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_care_site_id FOREIGN KEY (care_site_id) REFERENCES CARE_SITE (CARE_SITE_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_gender_source_concept_id FOREIGN KEY (gender_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_race_source_concept_id FOREIGN KEY (race_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PERSON ADD CONSTRAINT fpk_PERSON_ethnicity_source_concept_id FOREIGN KEY (ethnicity_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_observation_period_person_id') + THEN + ALTER TABLE OBSERVATION_PERIOD ADD CONSTRAINT fpk_OBSERVATION_PERIOD_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE OBSERVATION_PERIOD ADD CONSTRAINT fpk_OBSERVATION_PERIOD_period_type_concept_id FOREIGN KEY (period_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_visit_occurrence_person_id') + THEN + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_visit_concept_id FOREIGN KEY (visit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_visit_type_concept_id FOREIGN KEY (visit_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_care_site_id FOREIGN KEY (care_site_id) REFERENCES CARE_SITE (CARE_SITE_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_visit_source_concept_id FOREIGN KEY (visit_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_admitted_from_concept_id FOREIGN KEY (admitted_from_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_discharged_to_concept_id FOREIGN KEY (discharged_to_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_OCCURRENCE ADD CONSTRAINT fpk_VISIT_OCCURRENCE_preceding_visit_occurrence_id FOREIGN KEY (preceding_visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_visit_detail_person_id') + THEN + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_visit_detail_concept_id FOREIGN KEY (visit_detail_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_visit_detail_type_concept_id FOREIGN KEY (visit_detail_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_care_site_id FOREIGN KEY (care_site_id) REFERENCES CARE_SITE (CARE_SITE_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_visit_detail_source_concept_id FOREIGN KEY (visit_detail_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_admitted_from_concept_id FOREIGN KEY (admitted_from_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_discharged_to_concept_id FOREIGN KEY (discharged_to_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_preceding_visit_detail_id FOREIGN KEY (preceding_visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_parent_visit_detail_id FOREIGN KEY (parent_visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE VISIT_DETAIL ADD CONSTRAINT fpk_VISIT_DETAIL_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_condition_occurrence_person_id') + THEN + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_condition_concept_id FOREIGN KEY (condition_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_condition_type_concept_id FOREIGN KEY (condition_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_condition_status_concept_id FOREIGN KEY (condition_status_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE CONDITION_OCCURRENCE ADD CONSTRAINT fpk_CONDITION_OCCURRENCE_condition_source_concept_id FOREIGN KEY (condition_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_drug_exposure_person_id') + THEN + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_drug_concept_id FOREIGN KEY (drug_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_drug_type_concept_id FOREIGN KEY (drug_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_route_concept_id FOREIGN KEY (route_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE DRUG_EXPOSURE ADD CONSTRAINT fpk_DRUG_EXPOSURE_drug_source_concept_id FOREIGN KEY (drug_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_procedure_occurrence_person_id') + THEN + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_procedure_concept_id FOREIGN KEY (procedure_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_procedure_type_concept_id FOREIGN KEY (procedure_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_modifier_concept_id FOREIGN KEY (modifier_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE PROCEDURE_OCCURRENCE ADD CONSTRAINT fpk_PROCEDURE_OCCURRENCE_procedure_source_concept_id FOREIGN KEY (procedure_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_device_exposure_person_id') + THEN + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_device_concept_id FOREIGN KEY (device_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_device_type_concept_id FOREIGN KEY (device_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_device_source_concept_id FOREIGN KEY (device_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_unit_concept_id FOREIGN KEY (unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEVICE_EXPOSURE ADD CONSTRAINT fpk_DEVICE_EXPOSURE_unit_source_concept_id FOREIGN KEY (unit_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_measurement_person_id') + THEN + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_measurement_concept_id FOREIGN KEY (measurement_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_measurement_type_concept_id FOREIGN KEY (measurement_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_operator_concept_id FOREIGN KEY (operator_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_value_as_concept_id FOREIGN KEY (value_as_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_unit_concept_id FOREIGN KEY (unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_measurement_source_concept_id FOREIGN KEY (measurement_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_unit_source_concept_id FOREIGN KEY (unit_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE MEASUREMENT ADD CONSTRAINT fpk_MEASUREMENT_meas_event_field_concept_id FOREIGN KEY (meas_event_field_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_observation_person_id') + THEN + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_observation_concept_id FOREIGN KEY (observation_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_observation_type_concept_id FOREIGN KEY (observation_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_value_as_concept_id FOREIGN KEY (value_as_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_qualifier_concept_id FOREIGN KEY (qualifier_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_unit_concept_id FOREIGN KEY (unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_observation_source_concept_id FOREIGN KEY (observation_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE OBSERVATION ADD CONSTRAINT fpk_OBSERVATION_obs_event_field_concept_id FOREIGN KEY (obs_event_field_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_death_person_id') + THEN + + ALTER TABLE DEATH ADD CONSTRAINT fpk_DEATH_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE DEATH ADD CONSTRAINT fpk_DEATH_death_type_concept_id FOREIGN KEY (death_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEATH ADD CONSTRAINT fpk_DEATH_cause_concept_id FOREIGN KEY (cause_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DEATH ADD CONSTRAINT fpk_DEATH_cause_source_concept_id FOREIGN KEY (cause_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_note_person_id') + THEN + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_note_type_concept_id FOREIGN KEY (note_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_note_class_concept_id FOREIGN KEY (note_class_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_encoding_concept_id FOREIGN KEY (encoding_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_language_concept_id FOREIGN KEY (language_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_provider_id FOREIGN KEY (provider_id) REFERENCES PROVIDER (PROVIDER_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_visit_detail_id FOREIGN KEY (visit_detail_id) REFERENCES VISIT_DETAIL (VISIT_DETAIL_ID); + + ALTER TABLE NOTE ADD CONSTRAINT fpk_NOTE_note_event_field_concept_id FOREIGN KEY (note_event_field_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_note_nlp_section_concept_id') + THEN + + ALTER TABLE NOTE_NLP ADD CONSTRAINT fpk_NOTE_NLP_section_concept_id FOREIGN KEY (section_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE_NLP ADD CONSTRAINT fpk_NOTE_NLP_note_nlp_concept_id FOREIGN KEY (note_nlp_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE NOTE_NLP ADD CONSTRAINT fpk_NOTE_NLP_note_nlp_source_concept_id FOREIGN KEY (note_nlp_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_specimen_concept_id FOREIGN KEY (specimen_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_specimen_type_concept_id FOREIGN KEY (specimen_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_unit_concept_id FOREIGN KEY (unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_anatomic_site_concept_id FOREIGN KEY (anatomic_site_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SPECIMEN ADD CONSTRAINT fpk_SPECIMEN_disease_status_concept_id FOREIGN KEY (disease_status_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_fact_relationship_domain_concept_id_1') + THEN + + ALTER TABLE FACT_RELATIONSHIP ADD CONSTRAINT fpk_FACT_RELATIONSHIP_domain_concept_id_1 FOREIGN KEY (domain_concept_id_1) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE FACT_RELATIONSHIP ADD CONSTRAINT fpk_FACT_RELATIONSHIP_domain_concept_id_2 FOREIGN KEY (domain_concept_id_2) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE FACT_RELATIONSHIP ADD CONSTRAINT fpk_FACT_RELATIONSHIP_relationship_concept_id FOREIGN KEY (relationship_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_location_country_concept_id') + THEN + + ALTER TABLE LOCATION ADD CONSTRAINT fpk_LOCATION_country_concept_id FOREIGN KEY (country_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_care_site_place_of_service_concept_id') + THEN + + ALTER TABLE CARE_SITE ADD CONSTRAINT fpk_CARE_SITE_place_of_service_concept_id FOREIGN KEY (place_of_service_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CARE_SITE ADD CONSTRAINT fpk_CARE_SITE_location_id FOREIGN KEY (location_id) REFERENCES LOCATION (LOCATION_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_provider_specialty_concept_id') + THEN + + ALTER TABLE PROVIDER ADD CONSTRAINT fpk_PROVIDER_specialty_concept_id FOREIGN KEY (specialty_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROVIDER ADD CONSTRAINT fpk_PROVIDER_care_site_id FOREIGN KEY (care_site_id) REFERENCES CARE_SITE (CARE_SITE_ID); + + ALTER TABLE PROVIDER ADD CONSTRAINT fpk_PROVIDER_gender_concept_id FOREIGN KEY (gender_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROVIDER ADD CONSTRAINT fpk_PROVIDER_specialty_source_concept_id FOREIGN KEY (specialty_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PROVIDER ADD CONSTRAINT fpk_PROVIDER_gender_source_concept_id FOREIGN KEY (gender_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_payer_plan_period_payer_plan_period_id') + THEN + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_payer_plan_period_id FOREIGN KEY (payer_plan_period_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_payer_concept_id FOREIGN KEY (payer_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_payer_source_concept_id FOREIGN KEY (payer_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_plan_concept_id FOREIGN KEY (plan_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_plan_source_concept_id FOREIGN KEY (plan_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_sponsor_concept_id FOREIGN KEY (sponsor_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_sponsor_source_concept_id FOREIGN KEY (sponsor_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_stop_reason_concept_id FOREIGN KEY (stop_reason_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE PAYER_PLAN_PERIOD ADD CONSTRAINT fpk_PAYER_PLAN_PERIOD_stop_reason_source_concept_id FOREIGN KEY (stop_reason_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_cost_cost_domain_id') + THEN + + ALTER TABLE COST ADD CONSTRAINT fpk_COST_cost_domain_id FOREIGN KEY (cost_domain_id) REFERENCES DOMAIN (DOMAIN_ID); + + ALTER TABLE COST ADD CONSTRAINT fpk_COST_cost_type_concept_id FOREIGN KEY (cost_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE COST ADD CONSTRAINT fpk_COST_currency_concept_id FOREIGN KEY (currency_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE COST ADD CONSTRAINT fpk_COST_revenue_code_concept_id FOREIGN KEY (revenue_code_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE COST ADD CONSTRAINT fpk_COST_drg_concept_id FOREIGN KEY (drg_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_drug_era_person_id') + THEN + + ALTER TABLE DRUG_ERA ADD CONSTRAINT fpk_DRUG_ERA_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE DRUG_ERA ADD CONSTRAINT fpk_DRUG_ERA_drug_concept_id FOREIGN KEY (drug_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DOSE_ERA ADD CONSTRAINT fpk_DOSE_ERA_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE DOSE_ERA ADD CONSTRAINT fpk_DOSE_ERA_drug_concept_id FOREIGN KEY (drug_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DOSE_ERA ADD CONSTRAINT fpk_DOSE_ERA_unit_concept_id FOREIGN KEY (unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_condition_era_person_id') + THEN + + ALTER TABLE CONDITION_ERA ADD CONSTRAINT fpk_CONDITION_ERA_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE CONDITION_ERA ADD CONSTRAINT fpk_CONDITION_ERA_condition_concept_id FOREIGN KEY (condition_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_episode_person_id') + THEN + + ALTER TABLE EPISODE ADD CONSTRAINT fpk_EPISODE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE EPISODE ADD CONSTRAINT fpk_EPISODE_episode_concept_id FOREIGN KEY (episode_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE EPISODE ADD CONSTRAINT fpk_EPISODE_episode_object_concept_id FOREIGN KEY (episode_object_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE EPISODE ADD CONSTRAINT fpk_EPISODE_episode_type_concept_id FOREIGN KEY (episode_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE EPISODE ADD CONSTRAINT fpk_EPISODE_episode_source_concept_id FOREIGN KEY (episode_source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_episode_event_episode_id') + THEN + + ALTER TABLE EPISODE_EVENT ADD CONSTRAINT fpk_EPISODE_EVENT_episode_id FOREIGN KEY (episode_id) REFERENCES EPISODE (EPISODE_ID); + + ALTER TABLE EPISODE_EVENT ADD CONSTRAINT fpk_EPISODE_EVENT_episode_event_field_concept_id FOREIGN KEY (episode_event_field_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_metadata_metadata_concept_id') + THEN + + ALTER TABLE METADATA ADD CONSTRAINT fpk_METADATA_metadata_concept_id FOREIGN KEY (metadata_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE METADATA ADD CONSTRAINT fpk_METADATA_metadata_type_concept_id FOREIGN KEY (metadata_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE METADATA ADD CONSTRAINT fpk_METADATA_value_as_concept_id FOREIGN KEY (value_as_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_cdm_source_cdm_version_concept_id') + THEN + + ALTER TABLE CDM_SOURCE ADD CONSTRAINT fpk_CDM_SOURCE_cdm_version_concept_id FOREIGN KEY (cdm_version_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_concept_domain_id') + THEN + + ALTER TABLE CONCEPT ADD CONSTRAINT fpk_CONCEPT_domain_id FOREIGN KEY (domain_id) REFERENCES DOMAIN (DOMAIN_ID); + + ALTER TABLE CONCEPT ADD CONSTRAINT fpk_CONCEPT_vocabulary_id FOREIGN KEY (vocabulary_id) REFERENCES VOCABULARY (VOCABULARY_ID); + + ALTER TABLE CONCEPT ADD CONSTRAINT fpk_CONCEPT_concept_class_id FOREIGN KEY (concept_class_id) REFERENCES CONCEPT_CLASS (CONCEPT_CLASS_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_vocabulary_vocabulary_concept_id') + THEN + + ALTER TABLE VOCABULARY ADD CONSTRAINT fpk_VOCABULARY_vocabulary_concept_id FOREIGN KEY (vocabulary_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_domain_domain_concept_id') + THEN + + ALTER TABLE DOMAIN ADD CONSTRAINT fpk_DOMAIN_domain_concept_id FOREIGN KEY (domain_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_concept_class_concept_class_concept_id') + THEN + + ALTER TABLE CONCEPT_CLASS ADD CONSTRAINT fpk_CONCEPT_CLASS_concept_class_concept_id FOREIGN KEY (concept_class_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_concept_relationship_concept_id_1') + THEN + + ALTER TABLE CONCEPT_RELATIONSHIP ADD CONSTRAINT fpk_CONCEPT_RELATIONSHIP_concept_id_1 FOREIGN KEY (concept_id_1) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONCEPT_RELATIONSHIP ADD CONSTRAINT fpk_CONCEPT_RELATIONSHIP_concept_id_2 FOREIGN KEY (concept_id_2) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONCEPT_RELATIONSHIP ADD CONSTRAINT fpk_CONCEPT_RELATIONSHIP_relationship_id FOREIGN KEY (relationship_id) REFERENCES RELATIONSHIP (RELATIONSHIP_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_relationship_relationship_concept_id') + THEN + + ALTER TABLE RELATIONSHIP ADD CONSTRAINT fpk_RELATIONSHIP_relationship_concept_id FOREIGN KEY (relationship_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_concept_synonym_concept_id') + THEN + + ALTER TABLE CONCEPT_SYNONYM ADD CONSTRAINT fpk_CONCEPT_SYNONYM_concept_id FOREIGN KEY (concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONCEPT_SYNONYM ADD CONSTRAINT fpk_CONCEPT_SYNONYM_language_concept_id FOREIGN KEY (language_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_concept_ancestor_ancestor_concept_id') + THEN + + ALTER TABLE CONCEPT_ANCESTOR ADD CONSTRAINT fpk_CONCEPT_ANCESTOR_ancestor_concept_id FOREIGN KEY (ancestor_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE CONCEPT_ANCESTOR ADD CONSTRAINT fpk_CONCEPT_ANCESTOR_descendant_concept_id FOREIGN KEY (descendant_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_source_to_concept_map_source_concept_id') + THEN + + ALTER TABLE SOURCE_TO_CONCEPT_MAP ADD CONSTRAINT fpk_SOURCE_TO_CONCEPT_MAP_source_concept_id FOREIGN KEY (source_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SOURCE_TO_CONCEPT_MAP ADD CONSTRAINT fpk_SOURCE_TO_CONCEPT_MAP_target_concept_id FOREIGN KEY (target_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE SOURCE_TO_CONCEPT_MAP ADD CONSTRAINT fpk_SOURCE_TO_CONCEPT_MAP_target_vocabulary_id FOREIGN KEY (target_vocabulary_id) REFERENCES VOCABULARY (VOCABULARY_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_drug_strength_drug_concept_id') + THEN + + ALTER TABLE DRUG_STRENGTH ADD CONSTRAINT fpk_DRUG_STRENGTH_drug_concept_id FOREIGN KEY (drug_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_STRENGTH ADD CONSTRAINT fpk_DRUG_STRENGTH_ingredient_concept_id FOREIGN KEY (ingredient_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_STRENGTH ADD CONSTRAINT fpk_DRUG_STRENGTH_amount_unit_concept_id FOREIGN KEY (amount_unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_STRENGTH ADD CONSTRAINT fpk_DRUG_STRENGTH_numerator_unit_concept_id FOREIGN KEY (numerator_unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE DRUG_STRENGTH ADD CONSTRAINT fpk_DRUG_STRENGTH_denominator_unit_concept_id FOREIGN KEY (denominator_unit_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_cohort_cohort_definition_id') + THEN + + ALTER TABLE COHORT ADD CONSTRAINT fpk_COHORT_cohort_definition_id FOREIGN KEY (cohort_definition_id) REFERENCES COHORT_DEFINITION (COHORT_DEFINITION_ID); + + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_cohort_definition_definition_type_concept_id') + THEN + + ALTER TABLE COHORT_DEFINITION ADD CONSTRAINT fpk_COHORT_DEFINITION_definition_type_concept_id FOREIGN KEY (definition_type_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + ALTER TABLE COHORT_DEFINITION ADD CONSTRAINT fpk_COHORT_DEFINITION_subject_concept_id FOREIGN KEY (subject_concept_id) REFERENCES CONCEPT (CONCEPT_ID); + + END IF; + + -- FK: image_occurrence.person_id → person.person_id + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_image_occurrence_person_id') + THEN + + ALTER TABLE IMAGE_OCCURRENCE ADD CONSTRAINT fpk_IMAGE_OCCURRENCE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + END IF; + + -- FK: image_occurrence.procedure_occurrence_id → procedure_occurrence.procedure_occurrence_id + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_image_occurrence_procedure_occurrence_id') + THEN + + ALTER TABLE IMAGE_OCCURRENCE ADD CONSTRAINT fpk_IMAGE_OCCURRENCE_procedure_occurrence_id FOREIGN KEY (procedure_occurrence_id) REFERENCES PROCEDURE_OCCURRENCE (PROCEDURE_OCCURRENCE_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + END IF; + + -- FK: image_occurrence.visit_occurrence_id → visit_occurrence.visit_occurrence_id + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_image_occurrence_visit_occurrence_id') + THEN + + ALTER TABLE IMAGE_OCCURRENCE ADD CONSTRAINT fpk_IMAGE_OCCURRENCE_visit_occurrence_id FOREIGN KEY (visit_occurrence_id) REFERENCES VISIT_OCCURRENCE (VISIT_OCCURRENCE_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + END IF; + + -- FK: image_feature.person_id → person.person_id + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_image_feature_person_id') + THEN + + ALTER TABLE IMAGE_FEATURE ADD CONSTRAINT fpk_IMAGE_FEATURE_person_id FOREIGN KEY (person_id) REFERENCES PERSON (PERSON_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + END IF; + + -- FK: image_feature.image_occurrence_id → image_occurrence.image_occurrence_id + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'fpk_image_feature_image_occurrence_id') + THEN + + ALTER TABLE IMAGE_FEATURE ADD CONSTRAINT fpk_IMAGE_FEATURE_image_occurrence_id FOREIGN KEY (image_occurrence_id) REFERENCES IMAGE_OCCURRENCE (IMAGE_OCCURRENCE_ID) ON DELETE CASCADE ON UPDATE CASCADE; + + END IF; + +END$$ diff --git a/trust/omop-db/files/OMOPCDM_postgresql_5.4_ddl.sql b/trust/omop-db/files/OMOPCDM_postgresql_5.4_ddl.sql new file mode 100644 index 000000000..822ca8ed6 --- /dev/null +++ b/trust/omop-db/files/OMOPCDM_postgresql_5.4_ddl.sql @@ -0,0 +1,600 @@ +-- Portions derived from the OHDSI CommonDataModel project +-- (https://github.com/OHDSI/CommonDataModel, inst/ddl/5.4/postgresql), +-- extended with the MI-CDM medical-imaging tables. +-- Copyright (c) Observational Health Data Sciences and Informatics (OHDSI) +-- Licensed under the Apache License, Version 2.0. +-- SPDX-License-Identifier: Apache-2.0 +-- +-- Modifications Copyright (c) 2026 +-- Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +set schema 'omop'; + +--postgresql CDM DDL Specification for OMOP Common Data Model 5.4 + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS PERSON ( + person_id SERIAL PRIMARY KEY NOT NULL, + gender_concept_id integer NOT NULL, + year_of_birth integer NOT NULL, + month_of_birth integer NULL, + day_of_birth integer NULL, + birth_datetime TIMESTAMP NULL, + race_concept_id integer NOT NULL, + ethnicity_concept_id integer NOT NULL, + location_id integer NULL, + provider_id integer NULL, + care_site_id integer NULL, + person_source_value varchar(50) NULL, + gender_source_value varchar(50) NULL, + gender_source_concept_id integer NULL, + race_source_value varchar(50) NULL, + race_source_concept_id integer NULL, + ethnicity_source_value varchar(50) NULL, + ethnicity_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS OBSERVATION_PERIOD ( + observation_period_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + observation_period_start_date date NOT NULL, + observation_period_end_date date NOT NULL, + period_type_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS VISIT_OCCURRENCE ( + visit_occurrence_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + visit_concept_id integer NOT NULL, + visit_start_date date NOT NULL, + visit_start_datetime TIMESTAMP NULL, + visit_end_date date NOT NULL, + visit_end_datetime TIMESTAMP NULL, + visit_type_concept_id Integer NOT NULL, + provider_id integer NULL, + care_site_id integer NULL, + visit_source_value varchar(50) NULL, + visit_source_concept_id integer NULL, + admitted_from_concept_id integer NULL, + admitted_from_source_value varchar(50) NULL, + discharged_to_concept_id integer NULL, + discharged_to_source_value varchar(50) NULL, + preceding_visit_occurrence_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS VISIT_DETAIL ( + visit_detail_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + visit_detail_concept_id integer NOT NULL, + visit_detail_start_date date NOT NULL, + visit_detail_start_datetime TIMESTAMP NULL, + visit_detail_end_date date NOT NULL, + visit_detail_end_datetime TIMESTAMP NULL, + visit_detail_type_concept_id integer NOT NULL, + provider_id integer NULL, + care_site_id integer NULL, + visit_detail_source_value varchar(50) NULL, + visit_detail_source_concept_id Integer NULL, + admitted_from_concept_id Integer NULL, + admitted_from_source_value varchar(50) NULL, + discharged_to_source_value varchar(50) NULL, + discharged_to_concept_id integer NULL, + preceding_visit_detail_id integer NULL, + parent_visit_detail_id integer NULL, + visit_occurrence_id integer NOT NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS CONDITION_OCCURRENCE ( + condition_occurrence_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + condition_concept_id integer NOT NULL, + condition_start_date date NOT NULL, + condition_start_datetime TIMESTAMP NULL, + condition_end_date date NULL, + condition_end_datetime TIMESTAMP NULL, + condition_type_concept_id integer NOT NULL, + condition_status_concept_id integer NULL, + stop_reason varchar(20) NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + condition_source_value varchar(50) NULL, + condition_source_concept_id integer NULL, + condition_status_source_value varchar(50) NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS DRUG_EXPOSURE ( + drug_exposure_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + drug_concept_id integer NOT NULL, + drug_exposure_start_date date NOT NULL, + drug_exposure_start_datetime TIMESTAMP NULL, + drug_exposure_end_date date NOT NULL, + drug_exposure_end_datetime TIMESTAMP NULL, + verbatim_end_date date NULL, + drug_type_concept_id integer NOT NULL, + stop_reason varchar(20) NULL, + refills integer NULL, + quantity NUMERIC NULL, + days_supply integer NULL, + sig TEXT NULL, + route_concept_id integer NULL, + lot_number varchar(50) NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + drug_source_value varchar(50) NULL, + drug_source_concept_id integer NULL, + route_source_value varchar(50) NULL, + dose_unit_source_value varchar(50) NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS PROCEDURE_OCCURRENCE ( + procedure_occurrence_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + procedure_concept_id integer NOT NULL, + procedure_date date NOT NULL, + procedure_datetime TIMESTAMP NULL, + procedure_end_date date NULL, + procedure_end_datetime TIMESTAMP NULL, + procedure_type_concept_id integer NOT NULL, + modifier_concept_id integer NULL, + quantity integer NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + procedure_source_value varchar(50) NULL, + procedure_source_concept_id integer NULL, + modifier_source_value varchar(50) NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS DEVICE_EXPOSURE ( + device_exposure_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + device_concept_id integer NOT NULL, + device_exposure_start_date date NOT NULL, + device_exposure_start_datetime TIMESTAMP NULL, + device_exposure_end_date date NULL, + device_exposure_end_datetime TIMESTAMP NULL, + device_type_concept_id integer NOT NULL, + unique_device_id varchar(255) NULL, + production_id varchar(255) NULL, + quantity integer NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + device_source_value varchar(50) NULL, + device_source_concept_id integer NULL, + unit_concept_id integer NULL, + unit_source_value varchar(50) NULL, + unit_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS MEASUREMENT ( + measurement_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + measurement_concept_id integer NOT NULL, + measurement_date date NOT NULL, + measurement_datetime TIMESTAMP NULL, + measurement_time varchar(10) NULL, + measurement_type_concept_id integer NOT NULL, + operator_concept_id integer NULL, + value_as_number NUMERIC NULL, + value_as_concept_id integer NULL, + unit_concept_id integer NULL, + range_low NUMERIC NULL, + range_high NUMERIC NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + measurement_source_value varchar(50) NULL, + measurement_source_concept_id integer NULL, + unit_source_value varchar(50) NULL, + unit_source_concept_id integer NULL, + value_source_value varchar(50) NULL, + measurement_event_id bigint NULL, + meas_event_field_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS OBSERVATION ( + observation_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + observation_concept_id integer NOT NULL, + observation_date date NOT NULL, + observation_datetime TIMESTAMP NULL, + observation_type_concept_id integer NOT NULL, + value_as_number NUMERIC NULL, + value_as_string varchar(60) NULL, + value_as_concept_id Integer NULL, + qualifier_concept_id integer NULL, + unit_concept_id integer NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + observation_source_value varchar(50) NULL, + observation_source_concept_id integer NULL, + unit_source_value varchar(50) NULL, + qualifier_source_value varchar(50) NULL, + value_source_value varchar(50) NULL, + observation_event_id bigint NULL, + obs_event_field_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS DEATH ( + person_id integer NOT NULL, + death_date date NOT NULL, + death_datetime TIMESTAMP NULL, + death_type_concept_id integer NULL, + cause_concept_id integer NULL, + cause_source_value varchar(50) NULL, + cause_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS NOTE ( + note_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + note_date date NOT NULL, + note_datetime TIMESTAMP NULL, + note_type_concept_id integer NOT NULL, + note_class_concept_id integer NOT NULL, + note_title varchar(250) NULL, + note_text TEXT NOT NULL, + encoding_concept_id integer NOT NULL, + language_concept_id integer NOT NULL, + provider_id integer NULL, + visit_occurrence_id integer NULL, + visit_detail_id integer NULL, + note_source_value varchar(50) NULL, + note_event_id bigint NULL, + note_event_field_concept_id integer NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS NOTE_NLP ( + note_nlp_id SERIAL PRIMARY KEY NOT NULL, + note_id integer NOT NULL, + section_concept_id integer NULL, + snippet varchar(250) NULL, + "offset" varchar(50) NULL, + lexical_variant varchar(250) NOT NULL, + note_nlp_concept_id integer NULL, + note_nlp_source_concept_id integer NULL, + nlp_system varchar(250) NULL, + nlp_date date NOT NULL, + nlp_datetime TIMESTAMP NULL, + term_exists varchar(1) NULL, + term_temporal varchar(50) NULL, + term_modifiers varchar(2000) NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS SPECIMEN ( + specimen_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + specimen_concept_id integer NOT NULL, + specimen_type_concept_id integer NOT NULL, + specimen_date date NOT NULL, + specimen_datetime TIMESTAMP NULL, + quantity NUMERIC NULL, + unit_concept_id integer NULL, + anatomic_site_concept_id integer NULL, + disease_status_concept_id integer NULL, + specimen_source_id varchar(50) NULL, + specimen_source_value varchar(50) NULL, + unit_source_value varchar(50) NULL, + anatomic_site_source_value varchar(50) NULL, + disease_status_source_value varchar(50) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS FACT_RELATIONSHIP ( + domain_concept_id_1 integer NOT NULL, + fact_id_1 integer NOT NULL, + domain_concept_id_2 integer NOT NULL, + fact_id_2 integer NOT NULL, + relationship_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS LOCATION ( + location_id SERIAL PRIMARY KEY NOT NULL, + address_1 varchar(50) NULL, + address_2 varchar(50) NULL, + city varchar(50) NULL, + state varchar(2) NULL, + zip varchar(9) NULL, + county varchar(20) NULL, + location_source_value varchar(50) NULL, + country_concept_id integer NULL, + country_source_value varchar(80) NULL, + latitude NUMERIC NULL, + longitude NUMERIC NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CARE_SITE ( + care_site_id SERIAL PRIMARY KEY NOT NULL, + care_site_name varchar(255) NULL, + place_of_service_concept_id integer NULL, + location_id integer NULL, + care_site_source_value varchar(50) NULL, + place_of_service_source_value varchar(50) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS PROVIDER ( + provider_id SERIAL PRIMARY KEY NOT NULL, + provider_name varchar(255) NULL, + npi varchar(20) NULL, + dea varchar(20) NULL, + specialty_concept_id integer NULL, + care_site_id integer NULL, + year_of_birth integer NULL, + gender_concept_id integer NULL, + provider_source_value varchar(50) NULL, + specialty_source_value varchar(50) NULL, + specialty_source_concept_id integer NULL, + gender_source_value varchar(50) NULL, + gender_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS PAYER_PLAN_PERIOD ( + payer_plan_period_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + payer_plan_period_start_date date NOT NULL, + payer_plan_period_end_date date NOT NULL, + payer_concept_id integer NULL, + payer_source_value varchar(50) NULL, + payer_source_concept_id integer NULL, + plan_concept_id integer NULL, + plan_source_value varchar(50) NULL, + plan_source_concept_id integer NULL, + sponsor_concept_id integer NULL, + sponsor_source_value varchar(50) NULL, + sponsor_source_concept_id integer NULL, + family_source_value varchar(50) NULL, + stop_reason_concept_id integer NULL, + stop_reason_source_value varchar(50) NULL, + stop_reason_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS COST ( + cost_id SERIAL PRIMARY KEY NOT NULL, + cost_event_id integer NOT NULL, + cost_domain_id varchar(20) NOT NULL, + cost_type_concept_id integer NOT NULL, + currency_concept_id integer NULL, + total_charge NUMERIC NULL, + total_cost NUMERIC NULL, + total_paid NUMERIC NULL, + paid_by_payer NUMERIC NULL, + paid_by_patient NUMERIC NULL, + paid_patient_copay NUMERIC NULL, + paid_patient_coinsurance NUMERIC NULL, + paid_patient_deductible NUMERIC NULL, + paid_by_primary NUMERIC NULL, + paid_ingredient_cost NUMERIC NULL, + paid_dispensing_fee NUMERIC NULL, + payer_plan_period_id integer NULL, + amount_allowed NUMERIC NULL, + revenue_code_concept_id integer NULL, + revenue_code_source_value varchar(50) NULL, + drg_concept_id integer NULL, + drg_source_value varchar(3) NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS DRUG_ERA ( + drug_era_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + drug_concept_id integer NOT NULL, + drug_era_start_date TIMESTAMP NOT NULL, + drug_era_end_date TIMESTAMP NOT NULL, + drug_exposure_count integer NULL, + gap_days integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS DOSE_ERA ( + dose_era_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + drug_concept_id integer NOT NULL, + unit_concept_id integer NOT NULL, + dose_value NUMERIC NOT NULL, + dose_era_start_date TIMESTAMP NOT NULL, + dose_era_end_date TIMESTAMP NOT NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS CONDITION_ERA ( + condition_era_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + condition_concept_id integer NOT NULL, + condition_era_start_date TIMESTAMP NOT NULL, + condition_era_end_date TIMESTAMP NOT NULL, + condition_occurrence_count integer NULL ); + +--HINT DISTRIBUTE ON KEY (person_id) +CREATE TABLE IF NOT EXISTS EPISODE ( + episode_id bigint NOT NULL, + person_id bigint NOT NULL, + episode_concept_id integer NOT NULL, + episode_start_date date NOT NULL, + episode_start_datetime TIMESTAMP NULL, + episode_end_date date NULL, + episode_end_datetime TIMESTAMP NULL, + episode_parent_id bigint NULL, + episode_number integer NULL, + episode_object_concept_id integer NOT NULL, + episode_type_concept_id integer NOT NULL, + episode_source_value varchar(50) NULL, + episode_source_concept_id integer NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS EPISODE_EVENT ( + episode_id bigint NOT NULL, + event_id bigint NOT NULL, + episode_event_field_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS METADATA ( + metadata_id SERIAL PRIMARY KEY NOT NULL, + metadata_concept_id integer NOT NULL, + metadata_type_concept_id integer NOT NULL, + name varchar(250) NOT NULL, + value_as_string varchar(250) NULL, + value_as_concept_id integer NULL, + value_as_number NUMERIC NULL, + metadata_date date NULL, + metadata_datetime TIMESTAMP NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CDM_SOURCE ( + cdm_source_name varchar(255) NOT NULL, + cdm_source_abbreviation varchar(25) NOT NULL, + cdm_holder varchar(255) NOT NULL, + source_description TEXT NULL, + source_documentation_reference varchar(255) NULL, + cdm_etl_reference varchar(255) NULL, + source_release_date date NOT NULL, + cdm_release_date date NOT NULL, + cdm_version varchar(10) NULL, + cdm_version_concept_id integer NOT NULL, + vocabulary_version varchar(20) NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CONCEPT ( + concept_id integer NOT NULL, + concept_name varchar(255) NOT NULL, + domain_id varchar(20) NOT NULL, + vocabulary_id varchar(20) NOT NULL, + concept_class_id varchar(20) NOT NULL, + standard_concept varchar(1) NULL, + concept_code varchar(50) NOT NULL, + valid_start_date date NOT NULL, + valid_end_date date NOT NULL, + invalid_reason varchar(1) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS VOCABULARY ( + vocabulary_id varchar(20) NOT NULL, + vocabulary_name varchar(255) NOT NULL, + vocabulary_reference varchar(255) NULL, + vocabulary_version varchar(255) NULL, + vocabulary_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS DOMAIN ( + domain_id varchar(20) NOT NULL, + domain_name varchar(255) NOT NULL, + domain_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CONCEPT_CLASS ( + concept_class_id varchar(20) NOT NULL, + concept_class_name varchar(255) NOT NULL, + concept_class_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CONCEPT_RELATIONSHIP ( + concept_id_1 integer NOT NULL, + concept_id_2 integer NOT NULL, + relationship_id varchar(20) NOT NULL, + valid_start_date date NOT NULL, + valid_end_date date NOT NULL, + invalid_reason varchar(1) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS RELATIONSHIP ( + relationship_id varchar(20) NOT NULL, + relationship_name varchar(255) NOT NULL, + is_hierarchical varchar(1) NOT NULL, + defines_ancestry varchar(1) NOT NULL, + reverse_relationship_id varchar(20) NOT NULL, + relationship_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CONCEPT_SYNONYM ( + concept_id integer NOT NULL, + concept_synonym_name varchar(1000) NOT NULL, + language_concept_id integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS CONCEPT_ANCESTOR ( + ancestor_concept_id integer NOT NULL, + descendant_concept_id integer NOT NULL, + min_levels_of_separation integer NOT NULL, + max_levels_of_separation integer NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS SOURCE_TO_CONCEPT_MAP ( + source_code varchar(50) NOT NULL, + source_concept_id integer NOT NULL, + source_vocabulary_id varchar(20) NOT NULL, + source_code_description varchar(255) NULL, + target_concept_id integer NOT NULL, + target_vocabulary_id varchar(20) NOT NULL, + valid_start_date date NOT NULL, + valid_end_date date NOT NULL, + invalid_reason varchar(1) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS DRUG_STRENGTH ( + drug_concept_id integer NOT NULL, + ingredient_concept_id integer NOT NULL, + amount_value NUMERIC NULL, + amount_unit_concept_id integer NULL, + numerator_value NUMERIC NULL, + numerator_unit_concept_id integer NULL, + denominator_value NUMERIC NULL, + denominator_unit_concept_id integer NULL, + box_size integer NULL, + valid_start_date date NOT NULL, + valid_end_date date NOT NULL, + invalid_reason varchar(1) NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS COHORT ( + cohort_definition_id integer NOT NULL, + subject_id integer NOT NULL, + cohort_start_date date NOT NULL, + cohort_end_date date NOT NULL ); + +--HINT DISTRIBUTE ON RANDOM +CREATE TABLE IF NOT EXISTS COHORT_DEFINITION ( + cohort_definition_id SERIAL PRIMARY KEY NOT NULL, + cohort_definition_name varchar(255) NOT NULL, + cohort_definition_description TEXT NULL, + definition_type_concept_id integer NOT NULL, + cohort_definition_syntax TEXT NULL, + subject_concept_id integer NOT NULL, + cohort_initiation_date date NULL ); + +CREATE TABLE IF NOT EXISTS IMAGE_OCCURRENCE ( + image_occurrence_id SERIAL PRIMARY KEY NOT NULL, + person_id integer NOT NULL, + procedure_occurrence_id integer, + visit_occurrence_id integer, + anatomic_site_concept_id integer, + wadors_uri varchar(512), + local_path varchar(512), + image_occurrence_date DATE NOT NULL, + image_study_uid varchar(64), + image_series_uid varchar(64), + modality_concept_id integer ); + +ALTER TABLE IMAGE_OCCURRENCE ADD COLUMN IF NOT EXISTS accession_id varchar(255) NULL; + +CREATE TABLE IF NOT EXISTS IMAGE_FEATURE ( + image_feature_id SERIAL PRIMARY KEY, + person_id integer NOT NULL, + image_occurrence_id integer NOT NULL, + image_feature_event_field_concept_id integer, + image_feature_event_id integer, + image_feature_concept_id integer NOT NULL, + image_feature_type_concept_id integer NOT NULL, + image_finding_concept_id integer, + image_finding_id integer, + anatomic_site_concept_id integer, + alg_system varchar, + alg_datetime TIMESTAMP ); diff --git a/trust/omop-db/files/OMOPCDM_postgresql_5.4_indices.sql b/trust/omop-db/files/OMOPCDM_postgresql_5.4_indices.sql new file mode 100644 index 000000000..f85c038e2 --- /dev/null +++ b/trust/omop-db/files/OMOPCDM_postgresql_5.4_indices.sql @@ -0,0 +1,311 @@ +-- Portions derived from the OHDSI CommonDataModel project +-- (https://github.com/OHDSI/CommonDataModel, inst/ddl/5.4/postgresql), +-- extended with the MI-CDM medical-imaging tables. +-- Copyright (c) Observational Health Data Sciences and Informatics (OHDSI) +-- Licensed under the Apache License, Version 2.0. +-- SPDX-License-Identifier: Apache-2.0 +-- +-- Modifications Copyright (c) 2026 +-- Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +set schema 'omop'; + +/*postgresql OMOP CDM Indices + + There are no unique indices created because it is assumed that the primary key constraints have been run prior to + implementing indices. +*/ + + +/************************ + +Standardized clinical data + +************************/ +DO $$ +BEGIN + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_person_id') + THEN + CREATE INDEX idx_person_id ON person (person_id ASC); + CLUSTER person USING idx_person_id ; + CREATE INDEX idx_gender ON person (gender_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_observation_period_id_1') + THEN + CREATE INDEX idx_observation_period_id_1 ON observation_period (person_id ASC); + CLUSTER observation_period USING idx_observation_period_id_1 ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_visit_person_id_1') + THEN + CREATE INDEX idx_visit_person_id_1 ON visit_occurrence (person_id ASC); + CLUSTER visit_occurrence USING idx_visit_person_id_1 ; + CREATE INDEX idx_visit_concept_id_1 ON visit_occurrence (visit_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_visit_det_person_id_1') + THEN + CREATE INDEX idx_visit_det_person_id_1 ON visit_detail (person_id ASC); + CLUSTER visit_detail USING idx_visit_det_person_id_1 ; + CREATE INDEX idx_visit_det_concept_id_1 ON visit_detail (visit_detail_concept_id ASC); + CREATE INDEX idx_visit_det_occ_id ON visit_detail (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_condition_person_id_1') + THEN + CREATE INDEX idx_condition_person_id_1 ON condition_occurrence (person_id ASC); + CLUSTER condition_occurrence USING idx_condition_person_id_1 ; + CREATE INDEX idx_condition_concept_id_1 ON condition_occurrence (condition_concept_id ASC); + CREATE INDEX idx_condition_visit_id_1 ON condition_occurrence (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_drug_person_id_1') + THEN + CREATE INDEX idx_drug_person_id_1 ON drug_exposure (person_id ASC); + CLUSTER drug_exposure USING idx_drug_person_id_1 ; + CREATE INDEX idx_drug_concept_id_1 ON drug_exposure (drug_concept_id ASC); + CREATE INDEX idx_drug_visit_id_1 ON drug_exposure (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_procedure_person_id_1') + THEN + CREATE INDEX idx_procedure_person_id_1 ON procedure_occurrence (person_id ASC); + CLUSTER procedure_occurrence USING idx_procedure_person_id_1 ; + CREATE INDEX idx_procedure_concept_id_1 ON procedure_occurrence (procedure_concept_id ASC); + CREATE INDEX idx_procedure_visit_id_1 ON procedure_occurrence (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_device_person_id_1') + THEN + CREATE INDEX idx_device_person_id_1 ON device_exposure (person_id ASC); + CLUSTER device_exposure USING idx_device_person_id_1 ; + CREATE INDEX idx_device_concept_id_1 ON device_exposure (device_concept_id ASC); + CREATE INDEX idx_device_visit_id_1 ON device_exposure (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_measurement_person_id_1') + THEN + CREATE INDEX idx_measurement_person_id_1 ON measurement (person_id ASC); + CLUSTER measurement USING idx_measurement_person_id_1 ; + CREATE INDEX idx_measurement_concept_id_1 ON measurement (measurement_concept_id ASC); + CREATE INDEX idx_measurement_visit_id_1 ON measurement (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_observation_person_id_1') + THEN + CREATE INDEX idx_observation_person_id_1 ON observation (person_id ASC); + CLUSTER observation USING idx_observation_person_id_1 ; + CREATE INDEX idx_observation_concept_id_1 ON observation (observation_concept_id ASC); + CREATE INDEX idx_observation_visit_id_1 ON observation (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_death_person_id_1') + THEN + CREATE INDEX idx_death_person_id_1 ON death (person_id ASC); + CLUSTER death USING idx_death_person_id_1 ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_note_person_id_1') + THEN + CREATE INDEX idx_note_person_id_1 ON note (person_id ASC); + CLUSTER note USING idx_note_person_id_1 ; + CREATE INDEX idx_note_concept_id_1 ON note (note_type_concept_id ASC); + CREATE INDEX idx_note_visit_id_1 ON note (visit_occurrence_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_note_nlp_note_id_1') + THEN + CREATE INDEX idx_note_nlp_note_id_1 ON note_nlp (note_id ASC); + CLUSTER note_nlp USING idx_note_nlp_note_id_1 ; + CREATE INDEX idx_note_nlp_concept_id_1 ON note_nlp (note_nlp_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_specimen_person_id_1') + THEN + CREATE INDEX idx_specimen_person_id_1 ON specimen (person_id ASC); + CLUSTER specimen USING idx_specimen_person_id_1 ; + CREATE INDEX idx_specimen_concept_id_1 ON specimen (specimen_concept_id ASC); + END IF; + + + CREATE INDEX IF NOT EXISTS idx_fact_relationship_id1 ON fact_relationship (domain_concept_id_1 ASC); + CREATE INDEX IF NOT EXISTS idx_fact_relationship_id2 ON fact_relationship (domain_concept_id_2 ASC); + CREATE INDEX IF NOT EXISTS idx_fact_relationship_id3 ON fact_relationship (relationship_concept_id ASC); + + /************************ + + Standardized health system data + + ************************/ + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_location_id_1') + THEN + CREATE INDEX idx_location_id_1 ON location (location_id ASC); + CLUSTER location USING idx_location_id_1 ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_care_site_id_1') + THEN + CREATE INDEX idx_care_site_id_1 ON care_site (care_site_id ASC); + CLUSTER care_site USING idx_care_site_id_1 ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_provider_id_1') + THEN + CREATE INDEX idx_provider_id_1 ON provider (provider_id ASC); + CLUSTER provider USING idx_provider_id_1 ; + END IF; + + /************************ + + Standardized health economics + + ************************/ + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_period_person_id_1') + THEN + CREATE INDEX idx_period_person_id_1 ON payer_plan_period (person_id ASC); + CLUSTER payer_plan_period USING idx_period_person_id_1 ; + END IF; + + CREATE INDEX IF NOT EXISTS idx_cost_event_id ON cost (cost_event_id ASC); + + /************************ + + Standardized derived elements + + ************************/ + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_drug_era_person_id_1') + THEN + CREATE INDEX idx_drug_era_person_id_1 ON drug_era (person_id ASC); + CLUSTER drug_era USING idx_drug_era_person_id_1 ; + CREATE INDEX idx_drug_era_concept_id_1 ON drug_era (drug_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_dose_era_person_id_1') + THEN + CREATE INDEX idx_dose_era_person_id_1 ON dose_era (person_id ASC); + CLUSTER dose_era USING idx_dose_era_person_id_1 ; + CREATE INDEX idx_dose_era_concept_id_1 ON dose_era (drug_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_condition_era_person_id_1') + THEN + CREATE INDEX idx_condition_era_person_id_1 ON condition_era (person_id ASC); + CLUSTER condition_era USING idx_condition_era_person_id_1 ; + CREATE INDEX idx_condition_era_concept_id_1 ON condition_era (condition_concept_id ASC); + END IF; + + /************************** + + Standardized meta-data + + ***************************/ + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_metadata_concept_id_1') + THEN + CREATE INDEX idx_metadata_concept_id_1 ON metadata (metadata_concept_id ASC); + CLUSTER metadata USING idx_metadata_concept_id_1 ; + END IF; + + /************************** + + Standardized vocabularies + + ***************************/ + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_concept_concept_id') + THEN + CREATE INDEX idx_concept_concept_id ON concept (concept_id ASC); + CLUSTER concept USING idx_concept_concept_id ; + CREATE INDEX idx_concept_code ON concept (concept_code ASC); + CREATE INDEX idx_concept_vocabluary_id ON concept (vocabulary_id ASC); + CREATE INDEX idx_concept_domain_id ON concept (domain_id ASC); + CREATE INDEX idx_concept_class_id ON concept (concept_class_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_vocabulary_vocabulary_id') + THEN + CREATE INDEX idx_vocabulary_vocabulary_id ON vocabulary (vocabulary_id ASC); + CLUSTER vocabulary USING idx_vocabulary_vocabulary_id ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_domain_domain_id') + THEN + CREATE INDEX idx_domain_domain_id ON domain (domain_id ASC); + CLUSTER domain USING idx_domain_domain_id ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_concept_class_class_id') + THEN + CREATE INDEX idx_concept_class_class_id ON concept_class (concept_class_id ASC); + CLUSTER concept_class USING idx_concept_class_class_id ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_concept_relationship_id_1') + THEN + CREATE INDEX idx_concept_relationship_id_1 ON concept_relationship (concept_id_1 ASC); + CLUSTER concept_relationship USING idx_concept_relationship_id_1 ; + CREATE INDEX idx_concept_relationship_id_2 ON concept_relationship (concept_id_2 ASC); + CREATE INDEX idx_concept_relationship_id_3 ON concept_relationship (relationship_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_relationship_rel_id') + THEN + CREATE INDEX idx_relationship_rel_id ON relationship (relationship_id ASC); + CLUSTER relationship USING idx_relationship_rel_id ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_concept_synonym_id') + THEN + CREATE INDEX idx_concept_synonym_id ON concept_synonym (concept_id ASC); + CLUSTER concept_synonym USING idx_concept_synonym_id ; + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_concept_ancestor_id_1') + THEN + CREATE INDEX idx_concept_ancestor_id_1 ON concept_ancestor (ancestor_concept_id ASC); + CLUSTER concept_ancestor USING idx_concept_ancestor_id_1 ; + CREATE INDEX idx_concept_ancestor_id_2 ON concept_ancestor (descendant_concept_id ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_source_to_concept_map_3') + THEN + CREATE INDEX idx_source_to_concept_map_3 ON source_to_concept_map (target_concept_id ASC); + CLUSTER source_to_concept_map USING idx_source_to_concept_map_3 ; + CREATE INDEX idx_source_to_concept_map_1 ON source_to_concept_map (source_vocabulary_id ASC); + CREATE INDEX idx_source_to_concept_map_2 ON source_to_concept_map (target_vocabulary_id ASC); + CREATE INDEX idx_source_to_concept_map_c ON source_to_concept_map (source_code ASC); + END IF; + + IF NOT EXISTS(SELECT relname FROM PG_CLASS WHERE relname = 'idx_drug_strength_id_1') + THEN + CREATE INDEX idx_drug_strength_id_1 ON drug_strength (drug_concept_id ASC); + CLUSTER drug_strength USING idx_drug_strength_id_1 ; + CREATE INDEX idx_drug_strength_id_2 ON drug_strength (ingredient_concept_id ASC); + END IF; + + --Additional v6.0 indices + + --CREATE CLUSTERED INDEX IF NOT EXISTS idx_survey_person_id_1 ON survey_conduct (person_id ASC); + + + --CREATE CLUSTERED INDEX IF NOT EXISTS idx_episode_person_id_1 ON episode (person_id ASC); + --CREATE INDEX IF NOT EXISTS idx_episode_concept_id_1 ON episode (episode_concept_id ASC); + + --CREATE CLUSTERED INDEX IF NOT EXISTS idx_episode_event_id_1 ON episode_event (episode_id ASC); + --CREATE INDEX IF NOT EXISTS idx_ee_field_concept_id_1 ON episode_event (event_field_concept_id ASC); + +END$$; diff --git a/trust/omop-db/files/OMOPCDM_postgresql_5.4_primary_keys.sql b/trust/omop-db/files/OMOPCDM_postgresql_5.4_primary_keys.sql new file mode 100644 index 000000000..b45bd1483 --- /dev/null +++ b/trust/omop-db/files/OMOPCDM_postgresql_5.4_primary_keys.sql @@ -0,0 +1,82 @@ +-- Portions derived from the OHDSI CommonDataModel project +-- (https://github.com/OHDSI/CommonDataModel, inst/ddl/5.4/postgresql), +-- extended with the MI-CDM medical-imaging tables. +-- Copyright (c) Observational Health Data Sciences and Informatics (OHDSI) +-- Licensed under the Apache License, Version 2.0. +-- SPDX-License-Identifier: Apache-2.0 +-- +-- Modifications Copyright (c) 2026 +-- Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +set schema 'omop'; + +--postgresql CDM Primary Key Constraints for OMOP Common Data Model 5.4 + +DO $$ +BEGIN + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_episode') + THEN + ALTER TABLE EPISODE ADD CONSTRAINT xpk_EPISODE PRIMARY KEY (episode_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_concept') + THEN + ALTER TABLE CONCEPT ADD CONSTRAINT xpk_CONCEPT PRIMARY KEY (concept_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_vocabulary') + THEN + ALTER TABLE VOCABULARY ADD CONSTRAINT xpk_VOCABULARY PRIMARY KEY (vocabulary_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_domain') + THEN + ALTER TABLE DOMAIN ADD CONSTRAINT xpk_DOMAIN PRIMARY KEY (domain_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_concept_class') + THEN + ALTER TABLE CONCEPT_CLASS ADD CONSTRAINT xpk_CONCEPT_CLASS PRIMARY KEY (concept_class_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_relationship') + THEN + ALTER TABLE RELATIONSHIP ADD CONSTRAINT xpk_RELATIONSHIP PRIMARY KEY (relationship_id); + END IF; + + IF NOT EXISTS ( SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'omop' + AND CONSTRAINT_NAME = 'xpk_cohort') + THEN + ALTER TABLE COHORT ADD CONSTRAINT xpk_COHORT PRIMARY KEY (cohort_definition_id, subject_id, cohort_start_date, cohort_end_date); + END IF; + +END$$; diff --git a/trust/omop-db/files/create_readonly_users.sh b/trust/omop-db/files/create_readonly_users.sh new file mode 100755 index 000000000..46cb5c578 --- /dev/null +++ b/trust/omop-db/files/create_readonly_users.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Invoked by the postgres docker-entrypoint-initdb.d on first init. Reads the +# data analyst password from the environment and passes it to the SQL script +# via psql's -v variable mechanism, so it never appears in the image or in +# checked-in source. +set -euo pipefail + +if [ -z "${DATA_ACCESS_POSTGRES_PASSWORD:-}" ]; then + echo "ERROR: DATA_ACCESS_POSTGRES_PASSWORD must be set to create the data_analyst_reader role." >&2 + exit 1 +fi + +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -v "data_analyst_password=$DATA_ACCESS_POSTGRES_PASSWORD" \ + -f /flip/omop/create_readonly_users.sql diff --git a/trust/omop-db/files/create_readonly_users.sql b/trust/omop-db/files/create_readonly_users.sql new file mode 100644 index 000000000..9b5ed1df8 --- /dev/null +++ b/trust/omop-db/files/create_readonly_users.sql @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- ============================================================================= +-- PostgreSQL Read-Only User Management Script for FLIP OMOP Database +-- ============================================================================= +-- This script creates read-only users with SELECT-only permissions for the +-- OMOP database following security best practices. +-- +-- This script is invoked by create_readonly_users.sh, which passes the +-- data analyst password as a psql variable (-v data_analyst_password=...). +-- Running it directly with psql requires the same -v flag. +-- ============================================================================= +-- Create read-only role template (reusable for multiple users) +DO $$ +BEGIN + -- Create base read-only role if it doesn't exist + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'omop_readonly_base') THEN + CREATE ROLE omop_readonly_base; + + -- Grant basic database connection using current database + EXECUTE format('GRANT CONNECT ON DATABASE %I TO omop_readonly_base', current_database()); + + -- Grant schema usage + GRANT USAGE ON SCHEMA omop TO omop_readonly_base; + + -- Grant SELECT on all existing tables + GRANT SELECT ON ALL TABLES IN SCHEMA omop TO omop_readonly_base; + + -- Grant SELECT on all existing sequences (for ID columns, pagination) + GRANT SELECT ON ALL SEQUENCES IN SCHEMA omop TO omop_readonly_base; + + -- Ensure future tables are also readable + ALTER DEFAULT PRIVILEGES IN SCHEMA omop + GRANT SELECT ON TABLES TO omop_readonly_base; + + -- Ensure future sequences are also readable + ALTER DEFAULT PRIVILEGES IN SCHEMA omop + GRANT SELECT ON SEQUENCES TO omop_readonly_base; + + RAISE NOTICE 'Created base read-only role: omop_readonly_base for database: %', current_database(); + ELSE + RAISE NOTICE 'Base read-only role already exists: omop_readonly_base'; + END IF; +END +$$; + +-- ============================================================================= +-- Create specific read-only users +-- ============================================================================= + +-- 1. Data Analysis User +-- The password is supplied via the psql variable :'data_analyst_password'. +-- Substitution must happen outside any $$...$$ block, since psql does not +-- interpolate variables inside dollar-quoted strings. +SELECT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = 'data_analyst_reader' +) AS data_analyst_exists \gset + +\if :data_analyst_exists +\echo 'Data analyst user already exists: data_analyst_reader' +\else +CREATE ROLE data_analyst_reader WITH + LOGIN + PASSWORD :'data_analyst_password' + CONNECTION LIMIT 5; +GRANT omop_readonly_base TO data_analyst_reader; +ALTER ROLE data_analyst_reader SET statement_timeout = '300s'; +\echo 'Created data analyst read-only user: data_analyst_reader' +\endif + +-- ============================================================================= +-- Revoke dangerous permissions explicitly (defense in depth) +-- ============================================================================= + +-- Ensure read-only users cannot perform dangerous operations +DO $$ +DECLARE + readonly_user TEXT; + current_db TEXT; +BEGIN + -- Get current database name + SELECT current_database() INTO current_db; + + FOR readonly_user IN + SELECT rolname FROM pg_roles + WHERE rolname IN ('data_analyst_reader', 'omop_readonly_base') + LOOP + -- Revoke schema modification rights + EXECUTE format('REVOKE CREATE ON SCHEMA omop FROM %I', readonly_user); + EXECUTE format('REVOKE CREATE ON DATABASE %I FROM %I', current_db, readonly_user); + + -- Revoke table modification rights (should not be needed, but explicit is better) + EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA omop FROM %I', readonly_user); + + RAISE NOTICE 'Ensured read-only restrictions for user: %', readonly_user; + END LOOP; +END +$$; + +-- ============================================================================= +-- Verification queries +-- ============================================================================= + +-- List all roles and their permissions +SELECT + r.rolname as role_name, + r.rolcanlogin as can_login, + r.rolconnlimit as connection_limit, + array_agg(m.rolname) as member_of +FROM pg_roles r +LEFT JOIN pg_auth_members am ON r.oid = am.member +LEFT JOIN pg_roles m ON am.roleid = m.oid +WHERE r.rolname IN ('omop_readonly_base', 'data_analyst_reader') +GROUP BY r.rolname, r.rolcanlogin, r.rolconnlimit +ORDER BY r.rolname; + + +-- ============================================================================= +-- Security Notes: +-- ============================================================================= +-- 1. Passwords are injected from the environment (no defaults in this script); +-- rotate with ALTER ROLE + update the consuming service's env +-- 2. Consider using certificate-based authentication for production +-- 3. Set up connection pooling to limit resource usage +-- 4. Monitor query performance and adjust timeouts as needed +-- 5. Regularly audit user permissions and access patterns +-- 6. Consider row-level security (RLS) for additional data protection +-- ============================================================================= diff --git a/trust/omop-db/files/init.sql b/trust/omop-db/files/init.sql new file mode 100644 index 000000000..8ee4afc01 --- /dev/null +++ b/trust/omop-db/files/init.sql @@ -0,0 +1,13 @@ +-- Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- http://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +CREATE SCHEMA IF NOT EXISTS omop AUTHORIZATION postgres; +SET search_path TO omop, public; diff --git a/trust/omop-db/files/load_core_vocab.sh b/trust/omop-db/files/load_core_vocab.sh new file mode 100755 index 000000000..303469b8e --- /dev/null +++ b/trust/omop-db/files/load_core_vocab.sh @@ -0,0 +1,163 @@ +#!/bin/bash +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Streams the licensed core vocabulary bundle into a RUNNING OMOP database over +# an ordinary client connection (COPY FROM STDIN — no server-side file access, +# no image layer, no volume mount), then optionally applies the FK constraints. +# This is the one credentialed seeding step: published images and pgdata +# tarballs are vocab-free (FLIP#842/#843), so every environment loads the +# bundle it is licensed to hold. Idempotent: tables that already hold rows are +# skipped, and the constraints file guards itself with IF NOT EXISTS checks. +# The bundle is read only for the tables that actually need loading, so a run +# where everything is already present still applies the constraints without +# needing a bundle on disk at all. +# +# Usage: load_core_vocab.sh [--check] [constraints.sql] +# load_core_vocab.sh --check +# Connection from env: OMOP_DB_HOST (default localhost), OMOP_DB_PORT, +# OMOP_POSTGRES_USER, OMOP_POSTGRES_PASSWORD, OMOP_POSTGRES_DB. +# +# --check only probes the database and loads nothing, exiting 0 when every +# table already holds the core vocabulary. It exists so a caller can decide +# whether it needs to fetch the multi-GB bundle at all (the Kubernetes +# vocab-load Job does exactly this). Any non-zero status — including an +# unreachable database — means "could not confirm", and callers must treat that +# as "fetch and load": this script re-runs the same guards on the real load, so +# a false "not loaded" costs a download while a false "loaded" would silently +# leave the platform without a vocabulary. + +set -euo pipefail + +CHECK_ONLY=0 +if [ "${1:-}" = "--check" ]; then + CHECK_ONLY=1 + shift +fi + +VOCAB_DIR="${1:-}" +CONSTRAINTS_FILE="${2:-}" +if [ "${CHECK_ONLY}" -eq 0 ] && [ -z "${VOCAB_DIR}" ]; then + echo "usage: load_core_vocab.sh [--check] [constraints.sql]" >&2 + exit 1 +fi + +for var in OMOP_DB_PORT OMOP_POSTGRES_USER OMOP_POSTGRES_PASSWORD OMOP_POSTGRES_DB; do + if [ -z "${!var:-}" ]; then + echo "❌ ${var} must be set" >&2 + exit 1 + fi +done +if ! command -v psql > /dev/null 2>&1; then + echo "❌ psql not found — install postgresql-client" >&2 + exit 1 +fi + +# Load order and COPY options match the retired image-init script +# (populate_vocabulary_tables.sql): Athena TSV with header, QUOTE E'\b' +# effectively disables quoting. +TABLES="CONCEPT_ANCESTOR CONCEPT_CLASS CONCEPT_RELATIONSHIP CONCEPT_SYNONYM CONCEPT DOMAIN DRUG_STRENGTH RELATIONSHIP VOCABULARY" + +export PGPASSWORD="${OMOP_POSTGRES_PASSWORD}" +run_psql() { + psql -v ON_ERROR_STOP=1 -h "${OMOP_DB_HOST:-localhost}" -p "${OMOP_DB_PORT}" \ + -U "${OMOP_POSTGRES_USER}" -d "${OMOP_POSTGRES_DB}" "$@" +} + +# Per-table "already loaded" predicates. Four tables are SHARED with the DICOM +# vocabulary that ships inside the published pgdata tarballs, so a plain +# row-exists check would wrongly skip the core load on a freshly seeded trust — +# those use core-specific predicates instead (the DICOM rows live in the +# 2128xxxxxxx concept range / 'DICOM' vocabulary and never collide with the +# core bundle's rows, so a plain COPY appends cleanly alongside them). +core_present() { + case "$1" in + CONCEPT) + run_psql -tAc "SELECT EXISTS (SELECT 1 FROM omop.concept WHERE vocabulary_id = 'SNOMED')" ;; + VOCABULARY) + run_psql -tAc "SELECT EXISTS (SELECT 1 FROM omop.vocabulary WHERE vocabulary_id <> 'DICOM')" ;; + CONCEPT_CLASS) + run_psql -tAc "SELECT EXISTS (SELECT 1 FROM omop.concept_class WHERE concept_class_id NOT IN ('DICOM Attributes', 'DICOM Value Sets'))" ;; + CONCEPT_RELATIONSHIP) + run_psql -tAc "SELECT EXISTS (SELECT 1 FROM omop.concept_relationship WHERE concept_id_1 < 2128000000 AND concept_id_2 < 2128000000)" ;; + *) + run_psql -tAc "SELECT EXISTS (SELECT 1 FROM omop.$1)" ;; + esac +} + +# Pass 1 — ask the database what is missing. Nothing is read from the bundle +# here, so this is also the whole of --check: the expensive fetch a caller may +# be about to do can be decided on one round-trip per table. +PENDING="" +for table in ${TABLES}; do + # Deliberately a plain assignment, not `if [ "$(core_present …)" = "t" ]`: + # a command substitution inside an `if` condition is exempt from `set -e`, so + # a psql that failed for any reason (server restart, connection limit) would + # yield "", compare unequal to "t", and fall straight through to the COPY — + # silently duplicating rows in the four tables that carry no primary key. + present="$(core_present "${table}")" + case "${present}" in + t) + echo "⏭️ omop.${table} already holds the core vocabulary — skipping." + ;; + f) + PENDING="${PENDING} ${table}" + ;; + *) + echo "❌ unexpected guard result for omop.${table}: '${present}'" >&2 + exit 1 + ;; + esac +done + +if [ "${CHECK_ONLY}" -eq 1 ]; then + if [ -n "${PENDING}" ]; then + echo "📋 Core vocabulary still to load:${PENDING}" + exit 1 + fi + echo "✅ Core vocabulary already present in every table." + exit 0 +fi + +# No bundle at all is a different fault from an incomplete one, and worth saying +# so: a caller that probed first (see --check) skips fetching the bundle when it +# believes everything is loaded, so reaching here means the probe and this run +# disagreed. Blaming "an incomplete bundle" would send the operator to inspect an +# artifact that was deliberately never downloaded. +if [ -n "${PENDING}" ] && [ ! -d "${VOCAB_DIR}" ]; then + echo "❌ ${VOCAB_DIR} does not exist, but these tables still need loading:${PENDING}" >&2 + exit 1 +fi + +# Fail fast on an incomplete bundle before loading any of it — but only over the +# tables actually being loaded, so a no-op run still reaches the constraints +# below (which is the point: a previous run that loaded every table and then +# died before applying them must be recoverable by re-running). +for table in ${PENDING}; do + if [ ! -f "${VOCAB_DIR}/${table}.csv" ]; then + echo "❌ ${VOCAB_DIR}/${table}.csv missing — is this a complete vocabulary bundle?" >&2 + exit 1 + fi +done + +for table in ${PENDING}; do + echo "📥 Loading omop.${table} ..." + run_psql -c "COPY omop.${table} FROM STDIN WITH (FORMAT CSV, HEADER, DELIMITER E'\t', QUOTE E'\b')" \ + < "${VOCAB_DIR}/${table}.csv" +done + +if [ -n "${CONSTRAINTS_FILE}" ]; then + echo "🔗 Applying OMOP CDM constraints ..." + run_psql -f "${CONSTRAINTS_FILE}" +fi + +echo "✅ Core vocabulary load complete." diff --git a/trust/omop-db/pyproject.toml b/trust/omop-db/pyproject.toml new file mode 100644 index 000000000..f81851c06 --- /dev/null +++ b/trust/omop-db/pyproject.toml @@ -0,0 +1,63 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +[project] +name = "omop-db-tools" +version = "0.1.0" +description = "Build and populate tooling for the FLIP trust OMOP database image" +readme = "README.md" +authors = [ + { name = "A. Triay Bagur", email = "alexandre.triay_bagur@kcl.ac.uk" }, +] +requires-python = ">=3.12" +dependencies = [ + "pandas>=2.2.3", + "psycopg2-binary>=2.9.10", + "pydantic-settings>=2.11.0", + "sqlalchemy>=2.0.39", +] + +[dependency-groups] +dev = [ + "mypy>=1.15.0", + "pytest>=9.0.3", + "pytest-cov>=6.1.0", + "ruff>=0.11.4", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/omop_db_tools"] + +[tool.uv] +# 72h supply-chain cooldown; see CONTRIBUTING.md ("Dependency cooldown"). +exclude-newer = "3 days" + +[tool.pytest.ini_options] +python_files = ["test_*.py", "*_test.py"] +addopts = [] +filterwarnings = ["ignore::DeprecationWarning", "ignore::FutureWarning"] + +[tool.coverage.report] +exclude_lines = ["if __name__ == .__main__.:"] +omit = ["*.venv/*", "*/tests/*", "*/__init__.py"] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +preview = true +select = ['I', 'F', 'E', 'W', 'PT', 'UP006', 'UP007', 'UP035', 'UP042', 'UP045'] diff --git a/trust/omop-db/src/omop_db_tools/__init__.py b/trust/omop-db/src/omop_db_tools/__init__.py new file mode 100644 index 000000000..d50d353ec --- /dev/null +++ b/trust/omop-db/src/omop_db_tools/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Build and populate tooling for the FLIP trust OMOP database image.""" diff --git a/trust/omop-db/src/omop_db_tools/config.py b/trust/omop-db/src/omop_db_tools/config.py new file mode 100644 index 000000000..77fe340d7 --- /dev/null +++ b/trust/omop-db/src/omop_db_tools/config.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Connection settings for the OMOP database being populated.""" + +from functools import lru_cache + +from pydantic import SecretStr +from pydantic_settings import BaseSettings +from sqlalchemy import URL + + +class Settings(BaseSettings): + """Environment-driven connection settings. + + The populate scripts run on the host against a published container port, so + the host defaults to localhost; override OMOP_DB_HOST when running elsewhere. + """ + + OMOP_DB_HOST: str = "localhost" + OMOP_DB_PORT: int + OMOP_POSTGRES_USER: str + OMOP_POSTGRES_PASSWORD: SecretStr + OMOP_POSTGRES_DB: str + + @property + def OMOP_DATABASE_URL(self) -> SecretStr: + # URL.create escapes special characters (@, /, :) in the credentials. + return SecretStr( + URL.create( + "postgresql", + username=self.OMOP_POSTGRES_USER, + password=self.OMOP_POSTGRES_PASSWORD.get_secret_value(), + host=self.OMOP_DB_HOST, + port=self.OMOP_DB_PORT, + database=self.OMOP_POSTGRES_DB, + ).render_as_string(hide_password=False) + ) + + +@lru_cache +def get_settings() -> Settings: + """Get the application settings. + + Returns: + Settings: An instance of the Settings class containing configuration values. + """ + return Settings() # type: ignore[call-arg] diff --git a/trust/omop-db/src/omop_db_tools/dataset.py b/trust/omop-db/src/omop_db_tools/dataset.py new file mode 100644 index 000000000..01f125667 --- /dev/null +++ b/trust/omop-db/src/omop_db_tools/dataset.py @@ -0,0 +1,232 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Canonical mock-dataset handling: build, fetch, and per-trust splitting. + +The mock OMOP rows live as ONE canonical dataset (per project/cohort, one CSV per +table) published to the public Hugging Face dataset ``aicentreflip/trust-data`` +under ``omop-csv//``. Every row carries a ``source_trust`` provenance +column recording which of the two originally-generated trusts it came from. + +Standing up N trusts is a deterministic split of that single dataset: + +- ``legacy`` (default): partition by ``source_trust``. Reproduces the exact + original two-trust membership, keeping each trust's OMOP rows consistent with + the accession IDs present in that trust's published mock PACS (Orthanc) data. +- ``modulo``: partition by ``person_id % num_trusts``. Supports any trust count + for fresh stand-ups where the imaging data is regenerated to match. + +Every canonical table carries ``person_id``, so partitioning by person preserves +referential integrity across tables without re-keying. +""" + +import argparse +import urllib.error +import urllib.request +from pathlib import Path + +import pandas as pd + +# Insert order: person first. Populate targets constraint-free databases +# (constraints are applied only after the load), so order is convention, not FK +# correctness. +CANONICAL_TABLES = [ + "person", + "procedure_occurrence", + "visit_occurrence", + "image_occurrence", + "image_feature", + "measurement", + "observation", +] + +# Not every project ships every table (e.g. the cxr project has observations but +# no measurements; spleen the other way round). +OPTIONAL_TABLES = frozenset({"measurement", "observation"}) + +DEFAULT_PROJECTS = ["cxr_project", "spleen_project"] + +SOURCE_TRUST_COLUMN = "source_trust" + +HF_OMOP_CSV_BASE_URL = "https://huggingface.co/datasets/aicentreflip/trust-data/resolve/main/omop-csv" + +PARTITION_MODES = ["legacy", "modulo"] + + +def split_for_trust(df: pd.DataFrame, num_trusts: int, trust_index: int, mode: str = "legacy") -> pd.DataFrame: + """Return the deterministic slice of a canonical table belonging to one trust. + + Args: + df (pd.DataFrame): One canonical table (may carry the source_trust column). + num_trusts (int): Total number of trusts being stood up. + trust_index (int): 1-based index of the trust to extract. + mode (str): "legacy" (partition by source_trust) or "modulo" (partition by + person_id % num_trusts). + + Returns: + pd.DataFrame: The trust's rows, with the provenance column dropped. + + Raises: + ValueError: On invalid arguments, or when the requested mode cannot + partition the given frame (missing column / trust-count mismatch). + """ + if num_trusts < 1: + raise ValueError(f"num_trusts must be >= 1, got {num_trusts}") + if not 1 <= trust_index <= num_trusts: + raise ValueError(f"trust_index must be in [1, {num_trusts}], got {trust_index}") + if mode not in PARTITION_MODES: + raise ValueError(f"Unknown partition mode {mode!r}; expected one of {PARTITION_MODES}") + + if df.empty: + return df.drop(columns=[SOURCE_TRUST_COLUMN], errors="ignore") + + if "person_id" in df.columns and df["person_id"].isna().any(): + raise ValueError( + "person_id contains missing values — such rows would silently belong to no partition; " + "the canonical CSV is malformed" + ) + + if mode == "legacy": + if SOURCE_TRUST_COLUMN not in df.columns: + raise ValueError( + f"Legacy partitioning needs the {SOURCE_TRUST_COLUMN!r} column; " + "this frame does not carry it — use mode='modulo' instead" + ) + sources = set(df[SOURCE_TRUST_COLUMN].unique()) + if sources != set(range(1, num_trusts + 1)): + raise ValueError( + f"Legacy partitioning needs {SOURCE_TRUST_COLUMN} values to be exactly 1..{num_trusts}; " + f"this frame carries {sorted(sources)} — rebuild the canonical dataset or use mode='modulo'" + ) + part = df[df[SOURCE_TRUST_COLUMN] == trust_index] + else: + if "person_id" not in df.columns: + raise ValueError("Modulo partitioning needs a person_id column") + part = df[df["person_id"] % num_trusts == trust_index - 1] + + return part.drop(columns=[SOURCE_TRUST_COLUMN], errors="ignore").reset_index(drop=True) + + +def build_canonical(trust_dirs: list[Path], dest_dir: Path, projects: list[str] | None = None) -> None: + """Merge per-trust CSV exports into the canonical dataset with provenance. + + Args: + trust_dirs (list[Path]): Per-trust source directories, in trust order + (index i holds source_trust = i + 1); each contains /.csv. + dest_dir (Path): Output directory for the canonical /
.csv files. + projects (list[str] | None): Projects to merge; defaults to DEFAULT_PROJECTS. + + Raises: + FileNotFoundError: When a trust directory does not exist, a required + table is absent from every source, or a table is present in some + sources but missing from others (optionality is per-project by + design — per-trust asymmetry means a broken or mistyped export). + """ + for trust_dir in trust_dirs: + if not trust_dir.is_dir(): + raise FileNotFoundError(f"Trust source directory does not exist: {trust_dir}") + for project in projects or DEFAULT_PROJECTS: + out_dir = dest_dir / project + out_dir.mkdir(parents=True, exist_ok=True) + for table in CANONICAL_TABLES: + frames = [] + missing_from = [] + for trust_number, trust_dir in enumerate(trust_dirs, start=1): + csv_path = trust_dir / project / f"{table}.csv" + if not csv_path.is_file(): + missing_from.append(str(trust_dir)) + continue + frame = pd.read_csv(csv_path) + frame[SOURCE_TRUST_COLUMN] = trust_number + frames.append(frame) + if frames and missing_from: + raise FileNotFoundError( + f"{project}/{table}.csv is present in some sources but missing from {missing_from} — " + "rows from those trusts would be silently dropped" + ) + if not frames: + if table not in OPTIONAL_TABLES: + raise FileNotFoundError(f"No source CSV found for required table {project}/{table}") + print(f"⚠️ Optional table absent in all sources, skipping: {project}/{table}") + continue + merged = pd.concat(frames, ignore_index=True) + merged.to_csv(out_dir / f"{table}.csv", index=False) + print(f"✅ {project}/{table}.csv: {len(merged)} rows from {len(frames)} source trust(s)") + + +def fetch_canonical( + version: str, + dest_dir: Path, + projects: list[str] | None = None, + base_url: str = HF_OMOP_CSV_BASE_URL, +) -> None: + """Download one version of the canonical dataset (anonymous HTTPS, no credentials). + + Args: + version (str): Dataset version (the omop-csv// prefix on Hugging Face). + dest_dir (Path): Output directory for the canonical /
.csv files. + projects (list[str] | None): Projects to fetch; defaults to DEFAULT_PROJECTS. + base_url (str): Base URL holding the versioned dataset tree. + """ + for project in projects or DEFAULT_PROJECTS: + out_dir = dest_dir / project + out_dir.mkdir(parents=True, exist_ok=True) + for table in CANONICAL_TABLES: + url = f"{base_url}/{version}/{project}/{table}.csv" + try: + with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 + content = response.read() + except urllib.error.HTTPError as error: + if error.code == 404 and table in OPTIONAL_TABLES: + print(f"⚠️ Optional table not in dataset, skipping: {project}/{table}") + continue + raise RuntimeError( + f"Failed to fetch {url}: HTTP {error.code} (is version {version!r} published?)" + ) from error + if content.lstrip()[:1] == b"<": + raise RuntimeError( + f"{url} returned HTML, not CSV — check base_url (Hugging Face needs /resolve/, not /blob/)" + ) + (out_dir / f"{table}.csv").write_bytes(content) + print(f"⬇️ {project}/{table}.csv ({len(content)} bytes)") + + +def main(argv: list[str] | None = None) -> None: + """CLI entry point: build or fetch the canonical dataset.""" + parser = argparse.ArgumentParser(description="Build or fetch the canonical mock OMOP dataset.") + subparsers = parser.add_subparsers(dest="command", required=True) + + build_parser = subparsers.add_parser("build", help="Merge per-trust CSV exports into the canonical dataset.") + build_parser.add_argument( + "--trust-dirs", + nargs="+", + type=Path, + required=True, + help="Per-trust source directories in trust order (e.g. data/trust_1 data/trust_2).", + ) + build_parser.add_argument("--dest", type=Path, required=True, help="Output directory.") + build_parser.add_argument("--projects", nargs="+", default=None, help="Projects to merge (default: all).") + + fetch_parser = subparsers.add_parser("fetch", help="Download the canonical dataset from Hugging Face.") + fetch_parser.add_argument("--version", required=True, help="Dataset version (omop-csv// prefix).") + fetch_parser.add_argument("--dest", type=Path, required=True, help="Output directory.") + fetch_parser.add_argument("--projects", nargs="+", default=None, help="Projects to fetch (default: all).") + fetch_parser.add_argument("--base-url", default=HF_OMOP_CSV_BASE_URL, help="Override the dataset base URL.") + + args = parser.parse_args(argv) + if args.command == "build": + build_canonical(args.trust_dirs, args.dest, args.projects) + else: + fetch_canonical(args.version, args.dest, args.projects, args.base_url) + + +if __name__ == "__main__": + main() diff --git a/trust/omop-db/src/omop_db_tools/import_tables.py b/trust/omop-db/src/omop_db_tools/import_tables.py new file mode 100644 index 000000000..1f518eb06 --- /dev/null +++ b/trust/omop-db/src/omop_db_tools/import_tables.py @@ -0,0 +1,151 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Populate one trust's OMOP database from the canonical mock dataset. + +Reads the canonical //
.csv files (see dataset.py for +how they are built/fetched), extracts this trust's deterministic slice, and +appends it into the ``omop`` schema. + +Run against a freshly-initialised database: FK constraints are intentionally +absent until ``make apply-constraints`` runs after the data load. +""" + +import argparse +import re +from pathlib import Path + +import pandas as pd +from sqlalchemy import Engine, create_engine, text + +from omop_db_tools.config import get_settings +from omop_db_tools.dataset import CANONICAL_TABLES, DEFAULT_PROJECTS, OPTIONAL_TABLES, PARTITION_MODES, split_for_trust + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def validate_identifier(name: str) -> str: + """Allow a string into SQL as an identifier only if it is a plain SQL name. + + Args: + name (str): Candidate table/column name. + + Returns: + str: The validated name, unchanged. + + Raises: + ValueError: If the name is not a bare [A-Za-z_][A-Za-z0-9_]* identifier. + """ + if not _IDENTIFIER_RE.match(name): + raise ValueError(f"Unsafe SQL identifier: {name!r}") + return name + + +def validate_data_dir(data_dir: Path, projects: list[str]) -> None: + """Fail before any destructive step when a required table CSV is missing. + + Args: + data_dir (Path): Directory holding the canonical /
.csv files. + projects (list[str]): Project names that will be loaded. + + Raises: + FileNotFoundError: If any required table CSV is absent — checked up + front so clean_tables never wipes a database that cannot be refilled. + """ + for project in projects: + for table_name in CANONICAL_TABLES: + csv_file_path = data_dir / project / f"{table_name}.csv" + if not csv_file_path.is_file() and table_name not in OPTIONAL_TABLES: + raise FileNotFoundError(f"Required table CSV not found: {csv_file_path}") + + +def clean_tables(engine: Engine) -> None: + """Delete all rows from the mock-data tables. + + Order is the reverse of the insert order by convention only — populate + targets constraint-free databases, so deletion order is not load-bearing. + """ + with engine.begin() as conn: + for table_name in reversed(CANONICAL_TABLES): + print(f"🧹 Cleaning table: {table_name}") + conn.execute(text(f"DELETE FROM omop.{validate_identifier(table_name)};")) + print("✅ All target tables cleaned.\n") + + +def load_project( + engine: Engine, + data_dir: Path, + project: str, + num_trusts: int, + trust_index: int, + partition: str, +) -> None: + """Load one project's tables, filtered to this trust's slice of the canonical dataset.""" + print(f"📦 Loading data for trust {trust_index}/{num_trusts} / {project} (partition: {partition})") + for table_name in CANONICAL_TABLES: + csv_file_path = data_dir / project / f"{table_name}.csv" + if not csv_file_path.is_file(): + if table_name in OPTIONAL_TABLES: + print(f"⚠️ Optional table CSV not found, skipping: {csv_file_path}") + continue + raise FileNotFoundError(f"Required table CSV not found: {csv_file_path}") + + full = pd.read_csv(csv_file_path) + if full.empty and table_name not in OPTIONAL_TABLES: + raise ValueError(f"{csv_file_path} contains headers but no rows — refusing to load a truncated table") + df = split_for_trust(full, num_trusts, trust_index, partition) + df.to_sql(table_name, engine, if_exists="append", index=False, schema="omop") + print(f"✅ Inserted {len(df)} rows into omop.{table_name}") + print(" ") + + +def main(argv: list[str] | None = None) -> None: + """CLI entry point: clean the mock-data tables, then load this trust's slice.""" + parser = argparse.ArgumentParser(description="Load this trust's slice of the canonical CSV dataset into OMOP.") + parser.add_argument("--trust-index", type=int, required=True, help="1-based index of this trust.") + parser.add_argument("--num-trusts", type=int, default=2, help="Total number of trusts being stood up.") + parser.add_argument( + "--partition", + choices=PARTITION_MODES, + default="legacy", + help="Split mode: 'legacy' reproduces the original two-trust membership (consistent with the published " + "mock PACS data); 'modulo' partitions person_id %% num-trusts for any trust count.", + ) + parser.add_argument( + "--data-dir", + type=Path, + default=Path("data/canonical"), + help="Directory holding the canonical /
.csv files.", + ) + parser.add_argument( + "--projects", + nargs="+", + default=DEFAULT_PROJECTS, + help="Project names to load (default: %(default)s).", + ) + args = parser.parse_args(argv) + + validate_data_dir(args.data_dir, args.projects) + engine = create_engine(get_settings().OMOP_DATABASE_URL.get_secret_value(), echo=False) + + clean_tables(engine) + for project in args.projects: + load_project(engine, args.data_dir, project, args.num_trusts, args.trust_index, args.partition) + + with engine.begin() as conn: + result = conn.execute(text(f"SELECT COUNT(*) FROM omop.{validate_identifier(CANONICAL_TABLES[0])};")) + print(f"\nTotal rows in omop.{CANONICAL_TABLES[0]} (sanity check): {result.scalar()}") + + print(f"\n🎉 Finished populating OMOP database for trust {args.trust_index}/{args.num_trusts}") + + +if __name__ == "__main__": + main() diff --git a/trust/omop-db/src/omop_db_tools/load_dicom_vocab.py b/trust/omop-db/src/omop_db_tools/load_dicom_vocab.py new file mode 100644 index 000000000..d60fc4c04 --- /dev/null +++ b/trust/omop-db/src/omop_db_tools/load_dicom_vocab.py @@ -0,0 +1,249 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Load the DICOM vocabulary (NEMA PS3, freely redistributable) into OMOP. + +Adapted from https://github.com/paulnagy/DICOM2OMOP (Apache 2.0), specifically +dicom_standard_to_omop/load_dicom_to_omop.ipynb — see THIRD_PARTY_NOTICES.md. + +The vocab bundle (see ``make fetch-vocab-dicom``) ships plain CSVs only; the +upstream notebook's pickled relationship frame is converted to CSV when the +bundle is published, so nothing here deserialises pickles. +""" + +import argparse +import zipfile +from datetime import date, datetime +from pathlib import Path + +import pandas as pd +from sqlalchemy import Engine, create_engine, text + +from omop_db_tools.config import get_settings +from omop_db_tools.import_tables import validate_identifier + +DEFAULT_VOCAB_DIR = Path("data/vocab_dicom_paulnagy_20260109") + +REQUIRED_VOCAB_FILES = ( + "omop_table_staging_v5.csv", + "cs_values_maps_to.csv", + "cs_values_maps_to_value.csv", + "part3_to_part16_relationship_via_CID.csv", +) + + +def safe_insert(table: str, df: pd.DataFrame, engine: Engine) -> None: + """Insert rows with ON CONFLICT DO NOTHING, reporting inserted vs skipped. + + Duplicate-skipping relies on a primary key or unique constraint on the + target table (true for CONCEPT, VOCABULARY and CONCEPT_CLASS in this + schema). CONCEPT_RELATIONSHIP carries no unique constraint, so re-running + against an already-loaded database duplicates relationship rows — the + populate pipeline only ever targets freshly initialised databases. + + Args: + table (str): Target table name in the omop schema. + df (pd.DataFrame): Rows to insert; column names must be plain SQL identifiers. + engine (Engine): Target database engine. + """ + table = validate_identifier(table) + columns = [validate_identifier(column) for column in df.columns] + cols = ", ".join(columns) + placeholders = ", ".join([f":{column}" for column in columns]) + insert_sql = text(f"INSERT INTO omop.{table} ({cols}) VALUES ({placeholders}) ON CONFLICT DO NOTHING") + inserted = 0 + with engine.begin() as conn: + for _, row in df.iterrows(): + inserted += conn.execute(insert_sql, row.to_dict()).rowcount + print(f" omop.{table}: inserted={inserted} skipped={len(df) - inserted}") + + +def ensure_vocab_dir(vocab_dir: Path) -> None: + """Ensure the DICOM vocabulary directory exists and is complete, extracting its sibling zip if needed. + + Raises: + FileNotFoundError: When neither the directory nor its zip exists, or the + directory (pre-existing or freshly extracted) is missing expected + CSVs — e.g. after an interrupted extraction or a repacked bundle + whose top-level directory name doesn't match. + """ + if not (vocab_dir.exists() and any(vocab_dir.iterdir())): + vocab_zip = Path(f"{vocab_dir}.zip") + if not vocab_zip.is_file(): + raise FileNotFoundError(f"Neither {vocab_dir} nor {vocab_zip} exists — run `make fetch-vocab-dicom` first.") + print(f"Extracting {vocab_zip} → {vocab_dir} ...") + with zipfile.ZipFile(vocab_zip, "r") as zip_ref: + # zipfile.extractall does not sanitise ../ members — refuse any + # entry that would escape the extraction root (the zip is fetched, + # not repo-controlled). + for member in zip_ref.namelist(): + member_path = Path(member) + if member_path.is_absolute() or ".." in member_path.parts: + raise ValueError(f"Unsafe member path in {vocab_zip}: {member!r}") + zip_ref.extractall(vocab_dir.parent) + print("Extraction complete!") + else: + print(f"Using existing directory: {vocab_dir}") + missing = [name for name in REQUIRED_VOCAB_FILES if not (vocab_dir / name).is_file()] + if missing: + raise FileNotFoundError( + f"{vocab_dir} is missing {missing} — delete the directory and its .zip, " + "then re-run `make fetch-vocab-dicom`" + ) + + +def load_vocabulary_metadata(engine: Engine) -> None: + """Insert the DICOM VOCABULARY / CONCEPT_CLASS scaffolding concepts (2128000000-2).""" + print("Ensuring DICOM vocabulary and concept class concepts exist (2128000000-2)...") + df_vocab_concept = pd.DataFrame([ + { + "concept_id": 2128000000, + "concept_name": "Digital Imaging and Communications in Medicine (DICOM)", + "domain_id": "Metadata", + "vocabulary_id": "Vocabulary", + "concept_class_id": "Vocabulary", + "standard_concept": None, + "concept_code": "DICOM", + "valid_start_date": date(1970, 1, 1), + "valid_end_date": date(2099, 12, 31), + "invalid_reason": None, + }, + { + "concept_id": 2128000001, + "concept_name": "DICOM Attributes", + "domain_id": "Metadata", + "vocabulary_id": "Concept Class", + "concept_class_id": "Concept Class", + "standard_concept": None, + "concept_code": "DICOM", + "valid_start_date": date(1970, 1, 1), + "valid_end_date": date(2099, 12, 31), + "invalid_reason": None, + }, + { + "concept_id": 2128000002, + "concept_name": "DICOM Value Sets", + "domain_id": "Metadata", + "vocabulary_id": "Concept Class", + "concept_class_id": "Concept Class", + "standard_concept": None, + "concept_code": "DICOM", + "valid_start_date": date(1970, 1, 1), + "valid_end_date": date(2099, 12, 31), + "invalid_reason": None, + }, + ]) + safe_insert("CONCEPT", df_vocab_concept, engine) + + print("Loading DICOM VOCABULARY...") + df_vocab = pd.DataFrame([ + { + "vocabulary_id": "DICOM", + "vocabulary_name": "Digital Imaging and Communications in Medicine (NEMA)", + "vocabulary_reference": "https://www.dicomstandard.org/current", + "vocabulary_version": "NEMA Standard PS3", + "vocabulary_concept_id": 2128000000, + } + ]) + safe_insert("VOCABULARY", df_vocab, engine) + + print("Loading DICOM CONCEPT_CLASS...") + df_classes = pd.DataFrame([ + { + "concept_class_id": "DICOM Attributes", + "concept_class_name": "DICOM Attributes", + "concept_class_concept_id": 2128000001, + }, + { + "concept_class_id": "DICOM Value Sets", + "concept_class_name": "DICOM Value Sets", + "concept_class_concept_id": 2128000002, + }, + ]) + safe_insert("CONCEPT_CLASS", df_classes, engine) + + +def load_concepts(engine: Engine, vocab_dir: Path) -> None: + """Load the DICOM CONCEPT definitions from the staging CSV.""" + print("Loading DICOM CONCEPT definitions...") + omop_table_staging = pd.read_csv(vocab_dir / "omop_table_staging_v5.csv") + + # errors="raise": a malformed date should name the offending value here, not + # surface later as an opaque NOT NULL / adaptation failure inside the insert. + omop_table_staging["valid_start_date"] = pd.to_datetime( + omop_table_staging["valid_start_date"].astype(str), format="%Y%m%d", errors="raise" + ) + omop_table_staging["valid_end_date"] = pd.to_datetime( + omop_table_staging["valid_end_date"].astype(str), format="%Y%m%d", errors="raise" + ) + + # FIX: OMOP VARCHAR(1) fields should be NULL for DICOM + omop_table_staging["standard_concept"] = None + omop_table_staging["invalid_reason"] = None + + safe_insert("CONCEPT", omop_table_staging, engine) + + +def _drop_index_columns(df: pd.DataFrame) -> pd.DataFrame: + return df.loc[:, ~df.columns.str.contains("^Unnamed")] + + +def _fix_relationship_dates(df: pd.DataFrame) -> pd.DataFrame: + df["valid_start_date"] = datetime.strptime("19930101", "%Y%m%d").date() + df["valid_end_date"] = datetime.strptime("20991231", "%Y%m%d").date() + return df + + +def load_relationships(engine: Engine, vocab_dir: Path) -> None: + """Load CONCEPT_RELATIONSHIP rows from the three relationship CSVs.""" + print("Loading DICOM relationships...") + + # 1. Attributes-CID-ValueSets + concept_relationship_staging = pd.read_csv(vocab_dir / "part3_to_part16_relationship_via_CID.csv") + # 2. Attributes-Code String values + cs_values_maps_to_value = pd.read_csv(vocab_dir / "cs_values_maps_to_value.csv") + # 3. DICOM Code String to OMOP Standard coding systems + cs_values_maps_to = pd.read_csv(vocab_dir / "cs_values_maps_to.csv") + + df_relationships = pd.concat( + [ + _fix_relationship_dates(_drop_index_columns(concept_relationship_staging)), + _fix_relationship_dates(cs_values_maps_to_value), + _fix_relationship_dates(_drop_index_columns(cs_values_maps_to)), + ], + ignore_index=True, + ) + safe_insert("CONCEPT_RELATIONSHIP", df_relationships, engine) + + +def main(argv: list[str] | None = None) -> None: + """CLI entry point: load the full DICOM vocabulary into the OMOP database.""" + parser = argparse.ArgumentParser(description="Load the DICOM vocabulary tables into OMOP.") + parser.add_argument( + "--vocab-dir", + type=Path, + default=DEFAULT_VOCAB_DIR, + help="Directory holding the DICOM vocab CSVs (a sibling .zip is auto-extracted).", + ) + args = parser.parse_args(argv) + + print("🩻 Loading DICOM vocabulary tables...") + ensure_vocab_dir(args.vocab_dir) + engine = create_engine(get_settings().OMOP_DATABASE_URL.get_secret_value(), echo=False) + + load_vocabulary_metadata(engine) + load_concepts(engine, args.vocab_dir) + load_relationships(engine, args.vocab_dir) + print("DICOM vocabulary load complete!") + + +if __name__ == "__main__": + main() diff --git a/trust/omop-db/tests/test_load_core_vocab.sh b/trust/omop-db/tests/test_load_core_vocab.sh new file mode 100644 index 000000000..fd667a157 --- /dev/null +++ b/trust/omop-db/tests/test_load_core_vocab.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Black-box tests for files/load_core_vocab.sh — the --check probe, the two-pass +# load, and the fail-fast ordering. +# +# Drives the REAL script with `psql` stubbed on PATH (no Postgres, no bundle, no +# credentials). The stub answers the per-table guard queries from PRESENT_TABLES +# and appends every COPY / -f invocation to a log, so a case can assert not just +# the exit status but exactly which tables were written and in what order. +# +# These exit codes are load-bearing beyond this script: the Kubernetes +# omop-vocab-load Job decides whether to download the multi-GB licensed bundle +# purely from `--check`'s status, and a wrong 0 there leaves a trust with no +# vocabulary — a state in which every pod is healthy and only cohort queries +# joining omop.concept come back empty. +# +# Usage: +# bash trust/omop-db/tests/test_load_core_vocab.sh + +set -u + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/files/load_core_vocab.sh" + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "${TEST_ROOT}"' EXIT + +MOCKBIN="${TEST_ROOT}/mockbin" +mkdir -p "${MOCKBIN}" + +# Mock psql. Behaviour is driven per case by the environment: +# PRESENT_TABLES — space-separated lowercase table names whose guard returns 't' +# PSQL_FAIL — non-empty: exit 2 without answering (unreachable database) +# PSQL_GARBAGE — non-empty: answer guards with neither 't' nor 'f' +# PSQL_LOG — file receiving one line per COPY / -f invocation +# Guard queries name the table lowercase (omop.concept), COPY names it uppercase +# (omop.CONCEPT), so the table is matched case-insensitively. +cat > "${MOCKBIN}/psql" <<'MOCK_PSQL' +#!/usr/bin/env bash +if [[ -n "${PSQL_FAIL:-}" ]]; then + echo "psql: error: connection to server failed: Connection refused" >&2 + exit 2 +fi +args=("$@") +mode="" payload="" +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + -tAc) mode="query"; payload="${args[i + 1]:-}" ;; + -c) mode="copy"; payload="${args[i + 1]:-}" ;; + -f) mode="file"; payload="${args[i + 1]:-}" ;; + esac +done +case "${mode}" in + query) + if [[ -n "${PSQL_GARBAGE:-}" ]]; then echo "surprise"; exit 0; fi + table="$(printf '%s' "${payload}" | grep -oiE 'omop\.[a-z_]+' | head -1 | cut -d. -f2 | tr '[:upper:]' '[:lower:]')" + if [[ " ${PRESENT_TABLES:-} " == *" ${table} "* ]]; then echo "t"; else echo "f"; fi + ;; + copy) + table="$(printf '%s' "${payload}" | grep -oiE 'omop\.[a-z_]+' | head -1 | cut -d. -f2 | tr '[:upper:]' '[:lower:]')" + echo "COPY ${table}" >> "${PSQL_LOG}" + ;; + file) + echo "FILE ${payload}" >> "${PSQL_LOG}" + ;; +esac +exit 0 +MOCK_PSQL +chmod +x "${MOCKBIN}/psql" + +ALL_TABLES="concept_ancestor concept_class concept_relationship concept_synonym concept domain drug_strength relationship vocabulary" + +FAILURES=0 +CASE="" + +fail() { + echo " ✗ ${CASE}: $1" + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo " ✓ ${CASE}" +} + +# Runs the real script with the stub on PATH. Sets RC and LOG for assertions; +# OUT captures stdout+stderr. A bundle dir is only created when a case asks for +# one, so "the directory does not exist" is itself an exercisable state. +run_script() { + PSQL_LOG="${TEST_ROOT}/psql.log" + : > "${PSQL_LOG}" + # Every variable the script reads is set here, OMOP_DB_HOST included: under + # `make local_test` the kit env file's keys are all exported into this + # process, and a case must not depend on which of them happen to be set. + OUT="$(PATH="${MOCKBIN}:${PATH}" OMOP_DB_HOST=localhost \ + OMOP_DB_PORT=5432 OMOP_POSTGRES_USER=u OMOP_POSTGRES_PASSWORD=p OMOP_POSTGRES_DB=d \ + PRESENT_TABLES="${PRESENT_TABLES:-}" PSQL_FAIL="${PSQL_FAIL:-}" \ + PSQL_GARBAGE="${PSQL_GARBAGE:-}" PSQL_LOG="${PSQL_LOG}" \ + bash "${SCRIPT}" "$@" 2>&1)" + RC=$? + LOG="$(cat "${PSQL_LOG}")" +} + +# Builds a bundle directory holding a CSV per named table (uppercase filenames, +# as the real bundle ships them). No argument builds a complete bundle. +make_bundle() { + local dir="${TEST_ROOT}/bundle" + rm -rf "${dir}" + mkdir -p "${dir}" + local tables="${1:-${ALL_TABLES}}" + local t + for t in ${tables}; do + echo "header" > "${dir}/$(printf '%s' "${t}" | tr '[:lower:]' '[:upper:]').csv" + done + printf '%s' "${dir}" +} + +echo "load_core_vocab.sh" + +# ── --check ──────────────────────────────────────────────────────────────── +# The Job writes its skip marker on 0 and fetches on anything else, so 0 must +# mean "every table present" and nothing weaker. + +CASE="--check exits 0 when every table already holds the core vocabulary" +PRESENT_TABLES="${ALL_TABLES}" PSQL_FAIL="" PSQL_GARBAGE="" run_script --check +if [[ ${RC} -ne 0 ]]; then fail "expected exit 0, got ${RC}" +elif [[ -n "${LOG}" ]]; then fail "--check must not write to the database, logged: ${LOG}" +else pass; fi + +CASE="--check exits non-zero when a single table is missing" +PRESENT_TABLES="${ALL_TABLES/concept_synonym/}" PSQL_FAIL="" PSQL_GARBAGE="" run_script --check +if [[ ${RC} -eq 0 ]]; then fail "exited 0 with concept_synonym absent — the Job would skip the fetch and leave the trust vocabulary-less" +elif [[ "${OUT}" != *"CONCEPT_SYNONYM"* ]]; then fail "did not name the pending table: ${OUT}" +else pass; fi + +CASE="--check exits non-zero when nothing is loaded at all" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="" run_script --check +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero on an empty database"; else pass; fi + +CASE="--check exits non-zero when the database is unreachable" +PRESENT_TABLES="${ALL_TABLES}" PSQL_FAIL="1" PSQL_GARBAGE="" run_script --check +if [[ ${RC} -eq 0 ]]; then fail "an unreachable database must never read as 'already loaded'"; else pass; fi + +CASE="--check needs no bundle directory argument" +PRESENT_TABLES="${ALL_TABLES}" PSQL_FAIL="" PSQL_GARBAGE="" run_script --check +if [[ "${OUT}" == *"usage:"* ]]; then fail "tripped the usage guard: ${OUT}"; else pass; fi + +# ── load ─────────────────────────────────────────────────────────────────── + +CASE="loads only the tables that are missing" +BUNDLE="$(make_bundle)" +PRESENT_TABLES="concept_ancestor concept_class concept_relationship concept_synonym domain drug_strength relationship vocabulary" \ + PSQL_FAIL="" PSQL_GARBAGE="" run_script "${BUNDLE}" +if [[ ${RC} -ne 0 ]]; then fail "expected exit 0, got ${RC}: ${OUT}" +elif [[ "${LOG}" != "COPY concept" ]]; then fail "expected exactly 'COPY concept', got: ${LOG}" +else pass; fi + +CASE="applies the constraints after loading" +BUNDLE="$(make_bundle)" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="" run_script "${BUNDLE}" "${TEST_ROOT}/constraints.sql" +if [[ ${RC} -ne 0 ]]; then fail "expected exit 0, got ${RC}: ${OUT}" +elif [[ "$(printf '%s\n' "${LOG}" | grep -c '^COPY ')" -ne 9 ]]; then fail "expected 9 COPYs, got: ${LOG}" +elif [[ "$(printf '%s\n' "${LOG}" | tail -1)" != "FILE ${TEST_ROOT}/constraints.sql" ]]; then + fail "constraints must be applied last, log ended: $(printf '%s\n' "${LOG}" | tail -1)" +else pass; fi + +# The Kubernetes Job's no-bundle branch is legal only because of this: when the +# probe skipped the fetch there is no bundle on disk, and the loader must still +# reach the constraints instead of failing the whole Helm release. +CASE="no-op run needs no bundle on disk and still applies the constraints" +PRESENT_TABLES="${ALL_TABLES}" PSQL_FAIL="" PSQL_GARBAGE="" \ + run_script "${TEST_ROOT}/never-fetched" "${TEST_ROOT}/constraints.sql" +if [[ ${RC} -ne 0 ]]; then fail "expected exit 0 against an absent bundle dir, got ${RC}: ${OUT}" +elif [[ "${LOG}" == *"COPY"* ]]; then fail "must not load anything, logged: ${LOG}" +elif [[ "${LOG}" != "FILE ${TEST_ROOT}/constraints.sql" ]]; then fail "constraints were not applied: ${LOG}" +else pass; fi + +# An incomplete bundle must abort before the first COPY. Loading half a +# vocabulary is worse than loading none: the guards would then read the +# half-loaded tables as present and never retry them. +CASE="an incomplete bundle aborts before any table is loaded" +BUNDLE="$(make_bundle "concept_ancestor concept_class")" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="" run_script "${BUNDLE}" +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero on an incomplete bundle" +elif [[ -n "${LOG}" ]]; then fail "aborted too late — already wrote: ${LOG}" +elif [[ "${OUT}" != *"missing"* ]]; then fail "did not explain the missing file: ${OUT}" +else pass; fi + +# "No bundle at all" and "a bundle missing a file" are different faults. The +# first means a caller that probed first skipped the fetch and then disagreed +# with its own probe; pointing the operator at an incomplete artifact that was +# never downloaded would send them to inspect the wrong thing. +CASE="an absent bundle directory is distinguished from an incomplete one" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="" run_script "${TEST_ROOT}/never-fetched" +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero" +elif [[ -n "${LOG}" ]]; then fail "must not load anything, logged: ${LOG}" +elif [[ "${OUT}" != *"does not exist"* ]]; then fail "blamed an incomplete bundle instead: ${OUT}" +else pass; fi + +# The CSV check is scoped to the tables actually being loaded, so a bundle that +# omits a file for an already-present table is not an error. +CASE="a bundle may omit CSVs for tables that are already present" +BUNDLE="$(make_bundle "concept")" +PRESENT_TABLES="${ALL_TABLES/concept /}" PSQL_FAIL="" PSQL_GARBAGE="" run_script "${BUNDLE}" +if [[ ${RC} -ne 0 ]]; then fail "expected exit 0, got ${RC}: ${OUT}" +elif [[ "${LOG}" != "COPY concept" ]]; then fail "expected exactly 'COPY concept', got: ${LOG}" +else pass; fi + +# A guard that answers neither t nor f means the probe cannot be trusted. Falling +# through to the COPY would duplicate rows in the four tables with no primary key. +CASE="an unreadable guard result aborts instead of loading" +BUNDLE="$(make_bundle)" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="1" run_script "${BUNDLE}" +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero on an unexpected guard result" +elif [[ -n "${LOG}" ]]; then fail "must not load on an unexpected guard result, logged: ${LOG}" +else pass; fi + +CASE="an unreadable guard result fails --check too" +PRESENT_TABLES="" PSQL_FAIL="" PSQL_GARBAGE="1" run_script --check +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero on an unexpected guard result"; else pass; fi + +# ── usage ────────────────────────────────────────────────────────────────── + +CASE="no arguments is a usage error" +PRESENT_TABLES="${ALL_TABLES}" PSQL_FAIL="" PSQL_GARBAGE="" run_script +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero" +elif [[ "${OUT}" != *"usage:"* ]]; then fail "expected a usage message, got: ${OUT}" +else pass; fi + +# Passed empty rather than left unset: trust/omop-db/Makefile exports every key +# of the kit env file into its recipes, so under `make local_test` a real +# OMOP_POSTGRES_PASSWORD is in this process's environment and an "unset" case +# would silently test nothing. +CASE="a missing connection variable is a hard error" +OUT="$(PATH="${MOCKBIN}:${PATH}" OMOP_DB_PORT=5432 OMOP_POSTGRES_USER=u OMOP_POSTGRES_DB=d \ + OMOP_POSTGRES_PASSWORD="" bash "${SCRIPT}" --check 2>&1)" +RC=$? +if [[ ${RC} -eq 0 ]]; then fail "expected non-zero with OMOP_POSTGRES_PASSWORD empty" +elif [[ "${OUT}" != *"OMOP_POSTGRES_PASSWORD"* ]]; then fail "did not name the missing variable: ${OUT}" +else pass; fi + +echo +if [[ ${FAILURES} -gt 0 ]]; then + echo "❌ ${FAILURES} failing case(s)" + exit 1 +fi +echo "✅ all cases passed" diff --git a/trust/omop-db/tests/unit/test_dataset.py b/trust/omop-db/tests/unit/test_dataset.py new file mode 100644 index 000000000..cf02876bd --- /dev/null +++ b/trust/omop-db/tests/unit/test_dataset.py @@ -0,0 +1,253 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Unit tests for canonical dataset building, fetching, and per-trust splitting.""" + +import io +import urllib.error +import urllib.request +from contextlib import contextmanager +from pathlib import Path + +import pandas as pd +import pytest + +from omop_db_tools.dataset import SOURCE_TRUST_COLUMN, build_canonical, fetch_canonical, split_for_trust + + +def _canonical_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "person_id": [1, 2, 3, 4, 5, 6], + "year_of_birth": [1980, 1981, 1982, 1983, 1984, 1985], + SOURCE_TRUST_COLUMN: [1, 1, 1, 2, 2, 2], + } + ) + + +class TestSplitForTrust: + def test_legacy_reproduces_source_membership(self): + df = _canonical_frame() + trust_1 = split_for_trust(df, num_trusts=2, trust_index=1, mode="legacy") + trust_2 = split_for_trust(df, num_trusts=2, trust_index=2, mode="legacy") + + assert sorted(trust_1["person_id"]) == [1, 2, 3] + assert sorted(trust_2["person_id"]) == [4, 5, 6] + + def test_provenance_column_dropped(self): + df = _canonical_frame() + for mode in ["legacy", "modulo"]: + part = split_for_trust(df, num_trusts=2, trust_index=1, mode=mode) + assert SOURCE_TRUST_COLUMN not in part.columns + + def test_modulo_partitions_every_row_exactly_once(self): + df = _canonical_frame() + num_trusts = 3 + slices = [split_for_trust(df, num_trusts, index, mode="modulo") for index in range(1, num_trusts + 1)] + + all_person_ids = sorted(pid for part in slices for pid in part["person_id"]) + assert all_person_ids == sorted(df["person_id"]) + + def test_modulo_is_deterministic(self): + df = _canonical_frame() + first = split_for_trust(df, num_trusts=2, trust_index=1, mode="modulo") + second = split_for_trust(df, num_trusts=2, trust_index=1, mode="modulo") + pd.testing.assert_frame_equal(first, second) + + def test_dependent_table_follows_person_partition(self): + persons = _canonical_frame() + dependents = pd.DataFrame( + { + "visit_occurrence_id": range(12), + "person_id": [1, 2, 3, 4, 5, 6] * 2, + SOURCE_TRUST_COLUMN: [1, 1, 1, 2, 2, 2] * 2, + } + ) + for mode in ["legacy", "modulo"]: + person_part = split_for_trust(persons, num_trusts=2, trust_index=1, mode=mode) + dependent_part = split_for_trust(dependents, num_trusts=2, trust_index=1, mode=mode) + assert set(dependent_part["person_id"]) == set(person_part["person_id"]) + + def test_empty_frame_passes_through(self): + df = pd.DataFrame(columns=["person_id", SOURCE_TRUST_COLUMN]) + part = split_for_trust(df, num_trusts=2, trust_index=1, mode="legacy") + assert part.empty + assert SOURCE_TRUST_COLUMN not in part.columns + + @pytest.mark.parametrize( + ("num_trusts", "trust_index"), + [(0, 1), (2, 0), (2, 3), (-1, 1)], + ) + def test_invalid_indices_rejected(self, num_trusts, trust_index): + with pytest.raises(ValueError, match="must be"): + split_for_trust(_canonical_frame(), num_trusts, trust_index, mode="modulo") + + def test_unknown_mode_rejected(self): + with pytest.raises(ValueError, match="Unknown partition mode"): + split_for_trust(_canonical_frame(), 2, 1, mode="random") + + def test_legacy_without_provenance_column_rejected(self): + df = _canonical_frame().drop(columns=[SOURCE_TRUST_COLUMN]) + with pytest.raises(ValueError, match=SOURCE_TRUST_COLUMN): + split_for_trust(df, 2, 1, mode="legacy") + + def test_legacy_with_mismatched_trust_count_rejected(self): + with pytest.raises(ValueError, match="use mode='modulo'"): + split_for_trust(_canonical_frame(), num_trusts=3, trust_index=1, mode="legacy") + + def test_legacy_with_gapped_source_values_rejected(self): + df = _canonical_frame() + df[SOURCE_TRUST_COLUMN] = [1, 1, 1, 3, 3, 3] + with pytest.raises(ValueError, match=r"exactly 1\.\.2"): + split_for_trust(df, num_trusts=2, trust_index=1, mode="legacy") + + def test_nan_person_id_rejected(self): + df = _canonical_frame() + df.loc[0, "person_id"] = None + for mode in ["legacy", "modulo"]: + with pytest.raises(ValueError, match="missing values"): + split_for_trust(df, 2, 1, mode=mode) + + def test_modulo_without_person_id_rejected(self): + df = pd.DataFrame({"concept_id": [1, 2]}) + with pytest.raises(ValueError, match="person_id"): + split_for_trust(df, 2, 1, mode="modulo") + + +class TestBuildCanonical: + def _write_trust_dirs(self, root: Path) -> list[Path]: + trust_dirs = [] + for trust_number, person_ids in [(1, [1, 2]), (2, [3, 4])]: + trust_dir = root / f"trust_{trust_number}" + project_dir = trust_dir / "cxr_project" + project_dir.mkdir(parents=True) + pd.DataFrame({"person_id": person_ids, "year_of_birth": [1980, 1990]}).to_csv( + project_dir / "person.csv", index=False + ) + pd.DataFrame({"visit_occurrence_id": person_ids, "person_id": person_ids}).to_csv( + project_dir / "visit_occurrence.csv", index=False + ) + pd.DataFrame( + { + "image_occurrence_id": person_ids, + "person_id": person_ids, + "procedure_occurrence_id": person_ids, + "visit_occurrence_id": person_ids, + } + ).to_csv(project_dir / "image_occurrence.csv", index=False) + pd.DataFrame({"image_feature_id": person_ids, "person_id": person_ids}).to_csv( + project_dir / "image_feature.csv", index=False + ) + pd.DataFrame({"procedure_occurrence_id": person_ids, "person_id": person_ids}).to_csv( + project_dir / "procedure_occurrence.csv", index=False + ) + trust_dirs.append(trust_dir) + return trust_dirs + + def test_build_adds_provenance_and_roundtrips_via_legacy_split(self, tmp_path): + trust_dirs = self._write_trust_dirs(tmp_path) + dest = tmp_path / "canonical" + + build_canonical(trust_dirs, dest, projects=["cxr_project"]) + + merged = pd.read_csv(dest / "cxr_project" / "person.csv") + assert sorted(merged[SOURCE_TRUST_COLUMN].unique()) == [1, 2] + assert len(merged) == 4 + + trust_1 = split_for_trust(merged, num_trusts=2, trust_index=1, mode="legacy") + original_trust_1 = pd.read_csv(trust_dirs[0] / "cxr_project" / "person.csv") + pd.testing.assert_frame_equal(trust_1, original_trust_1) + + def test_optional_tables_may_be_absent(self, tmp_path): + trust_dirs = self._write_trust_dirs(tmp_path) + dest = tmp_path / "canonical" + + build_canonical(trust_dirs, dest, projects=["cxr_project"]) + + assert not (dest / "cxr_project" / "measurement.csv").exists() + assert not (dest / "cxr_project" / "observation.csv").exists() + + def test_missing_required_table_rejected(self, tmp_path): + trust_dirs = self._write_trust_dirs(tmp_path) + (trust_dirs[0] / "cxr_project" / "person.csv").unlink() + (trust_dirs[1] / "cxr_project" / "person.csv").unlink() + + with pytest.raises(FileNotFoundError, match="person"): + build_canonical(trust_dirs, tmp_path / "canonical", projects=["cxr_project"]) + + def test_nonexistent_trust_dir_rejected(self, tmp_path): + trust_dirs = self._write_trust_dirs(tmp_path) + with pytest.raises(FileNotFoundError, match="does not exist"): + build_canonical([trust_dirs[0], tmp_path / "trsut_2"], tmp_path / "canonical") + + def test_table_missing_from_one_source_rejected(self, tmp_path): + trust_dirs = self._write_trust_dirs(tmp_path) + (trust_dirs[1] / "cxr_project" / "person.csv").unlink() + + with pytest.raises(FileNotFoundError, match="silently dropped"): + build_canonical(trust_dirs, tmp_path / "canonical", projects=["cxr_project"]) + + +@contextmanager +def _fake_response(body: bytes): + yield io.BytesIO(body) + + +def _fake_urlopen(responses: dict[str, bytes]): + """Build an urlopen stub serving canned bodies; unknown URLs get a 404.""" + + def opener(url, timeout=0): + for fragment, body in responses.items(): + if fragment in url: + return _fake_response(body) + raise urllib.error.HTTPError(url, 404, "Not Found", None, None) + + return opener + + +class TestFetchCanonical: + CSV = b"person_id,source_trust\n1,1\n" + + def test_optional_404_skipped_required_fetched(self, tmp_path, monkeypatch): + required = ["person", "procedure_occurrence", "visit_occurrence", "image_occurrence", "image_feature"] + served = {f"/{table}.csv": self.CSV for table in required} + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen(served)) + + fetch_canonical("v1", tmp_path, projects=["cxr_project"]) + + assert (tmp_path / "cxr_project" / "person.csv").read_bytes() == self.CSV + assert not (tmp_path / "cxr_project" / "measurement.csv").exists() + assert not (tmp_path / "cxr_project" / "observation.csv").exists() + + def test_required_404_raises_with_version_hint(self, tmp_path, monkeypatch): + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen({})) + + with pytest.raises(RuntimeError, match="version 'v9'"): + fetch_canonical("v9", tmp_path, projects=["cxr_project"]) + + def test_non_404_error_on_optional_table_raises(self, tmp_path, monkeypatch): + def opener(url, timeout=0): + if "/measurement.csv" in url: + raise urllib.error.HTTPError(url, 500, "Server Error", None, None) + return _fake_response(self.CSV) + + monkeypatch.setattr(urllib.request, "urlopen", opener) + + with pytest.raises(RuntimeError, match="HTTP 500"): + fetch_canonical("v1", tmp_path, projects=["cxr_project"]) + + def test_html_response_rejected(self, tmp_path, monkeypatch): + monkeypatch.setattr( + urllib.request, "urlopen", lambda url, timeout=0: _fake_response(b"") + ) + + with pytest.raises(RuntimeError, match="HTML, not CSV"): + fetch_canonical("v1", tmp_path, projects=["cxr_project"]) diff --git a/trust/omop-db/tests/unit/test_import_tables.py b/trust/omop-db/tests/unit/test_import_tables.py new file mode 100644 index 000000000..70a5d674e --- /dev/null +++ b/trust/omop-db/tests/unit/test_import_tables.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Unit tests for the table-import helpers (no database required).""" + +import pandas as pd +import pytest + +from omop_db_tools.dataset import CANONICAL_TABLES, SOURCE_TRUST_COLUMN +from omop_db_tools.import_tables import load_project, validate_data_dir, validate_identifier + + +def _write_project(data_dir, project, tables, rows=2): + project_dir = data_dir / project + project_dir.mkdir(parents=True, exist_ok=True) + for table in tables: + pd.DataFrame( + { + "person_id": range(1, rows + 1), + SOURCE_TRUST_COLUMN: [1] * rows, + } + ).to_csv(project_dir / f"{table}.csv", index=False) + return project_dir + + +class TestValidateIdentifier: + @pytest.mark.parametrize("name", ["person", "image_occurrence", "CONCEPT", "_private"]) + def test_plain_identifiers_accepted(self, name): + assert validate_identifier(name) == name + + @pytest.mark.parametrize("name", ["person; DROP TABLE x", "Unnamed: 0", "a-b", "1st", ""]) + def test_unsafe_identifiers_rejected(self, name): + with pytest.raises(ValueError, match="Unsafe SQL identifier"): + validate_identifier(name) + + +class TestValidateDataDir: + def test_all_required_present_passes(self, tmp_path): + required = [table for table in CANONICAL_TABLES if table not in {"measurement", "observation"}] + _write_project(tmp_path, "cxr_project", required) + + validate_data_dir(tmp_path, ["cxr_project"]) + + def test_missing_required_table_rejected(self, tmp_path): + _write_project(tmp_path, "cxr_project", ["person"]) + + with pytest.raises(FileNotFoundError, match="procedure_occurrence"): + validate_data_dir(tmp_path, ["cxr_project"]) + + +class TestLoadProject: + def _record_to_sql(self, monkeypatch): + loaded = [] + + def fake_to_sql(self_df, name, engine, **kwargs): + loaded.append((name, len(self_df))) + + monkeypatch.setattr(pd.DataFrame, "to_sql", fake_to_sql) + return loaded + + def test_optional_missing_skipped_and_rest_loaded(self, tmp_path, monkeypatch): + required = [table for table in CANONICAL_TABLES if table not in {"measurement", "observation"}] + _write_project(tmp_path, "cxr_project", required) + loaded = self._record_to_sql(monkeypatch) + + load_project(None, tmp_path, "cxr_project", num_trusts=1, trust_index=1, partition="legacy") + + assert [name for name, _ in loaded] == required + + def test_headers_only_required_table_rejected(self, tmp_path, monkeypatch): + required = [table for table in CANONICAL_TABLES if table not in {"measurement", "observation"}] + project_dir = _write_project(tmp_path, "cxr_project", required) + (project_dir / "person.csv").write_text(f"person_id,{SOURCE_TRUST_COLUMN}\n") + self._record_to_sql(monkeypatch) + + with pytest.raises(ValueError, match="headers but no rows"): + load_project(None, tmp_path, "cxr_project", num_trusts=1, trust_index=1, partition="legacy") + + def test_missing_required_table_rejected(self, tmp_path, monkeypatch): + _write_project(tmp_path, "cxr_project", ["person"]) + self._record_to_sql(monkeypatch) + + with pytest.raises(FileNotFoundError, match="procedure_occurrence"): + load_project(None, tmp_path, "cxr_project", num_trusts=1, trust_index=1, partition="legacy") diff --git a/trust/omop-db/tests/unit/test_load_dicom_vocab.py b/trust/omop-db/tests/unit/test_load_dicom_vocab.py new file mode 100644 index 000000000..a2221b6fb --- /dev/null +++ b/trust/omop-db/tests/unit/test_load_dicom_vocab.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Unit tests for the DICOM vocabulary loader helpers (no database required).""" + +import zipfile +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +from omop_db_tools.load_dicom_vocab import REQUIRED_VOCAB_FILES, ensure_vocab_dir, safe_insert + + +class TestSafeInsert: + def _engine(self, rowcounts): + engine = MagicMock() + conn = engine.begin.return_value.__enter__.return_value + conn.execute.side_effect = [MagicMock(rowcount=count) for count in rowcounts] + return engine, conn + + def test_emits_on_conflict_do_nothing_and_counts(self, capsys): + engine, conn = self._engine([1, 0]) + df = pd.DataFrame({"concept_id": [1, 2], "concept_name": ["a", "b"]}) + + safe_insert("CONCEPT", df, engine) + + assert conn.execute.call_count == 2 + statement = str(conn.execute.call_args_list[0].args[0]) + assert "ON CONFLICT DO NOTHING" in statement + assert "INSERT INTO omop.CONCEPT (concept_id, concept_name)" in statement + assert "inserted=1 skipped=1" in capsys.readouterr().out + + def test_unsafe_table_rejected(self): + engine, _ = self._engine([]) + with pytest.raises(ValueError, match="Unsafe SQL identifier"): + safe_insert("CONCEPT; DROP TABLE x", pd.DataFrame({"a": [1]}), engine) + + def test_unsafe_column_rejected(self): + engine, _ = self._engine([]) + with pytest.raises(ValueError, match="Unsafe SQL identifier"): + safe_insert("CONCEPT", pd.DataFrame({"Unnamed: 0": [1]}), engine) + + +class TestEnsureVocabDir: + def _write_zip(self, tmp_path, names): + bundle = tmp_path / "bundle" + with zipfile.ZipFile(tmp_path / "bundle.zip", "w") as zip_ref: + for name in names: + zip_ref.writestr(f"bundle/{name}", "concept_id\n1\n") + return bundle + + def test_extracts_zip_when_dir_absent(self, tmp_path): + bundle = self._write_zip(tmp_path, REQUIRED_VOCAB_FILES) + + ensure_vocab_dir(bundle) + + for name in REQUIRED_VOCAB_FILES: + assert (bundle / name).is_file() + + def test_existing_complete_dir_accepted(self, tmp_path): + bundle = tmp_path / "bundle" + bundle.mkdir() + for name in REQUIRED_VOCAB_FILES: + (bundle / name).write_text("concept_id\n1\n") + + ensure_vocab_dir(bundle) + + def test_incomplete_dir_rejected_with_remediation(self, tmp_path): + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / REQUIRED_VOCAB_FILES[0]).write_text("concept_id\n1\n") + + with pytest.raises(FileNotFoundError, match="fetch-vocab-dicom"): + ensure_vocab_dir(bundle) + + def test_neither_dir_nor_zip_rejected(self, tmp_path): + with pytest.raises(FileNotFoundError, match="fetch-vocab-dicom"): + ensure_vocab_dir(tmp_path / "bundle") + + def test_zip_with_traversal_member_rejected(self, tmp_path): + with zipfile.ZipFile(tmp_path / "bundle.zip", "w") as zip_ref: + zip_ref.writestr("../evil.csv", "concept_id\n1\n") + + with pytest.raises(ValueError, match="Unsafe member path"): + ensure_vocab_dir(tmp_path / "bundle") + + assert not (tmp_path.parent / "evil.csv").exists() diff --git a/trust/omop-db/uv.lock b/trust/omop-db/uv.lock new file mode 100644 index 000000000..bcb2457ba --- /dev/null +++ b/trust/omop-db/uv.lock @@ -0,0 +1,795 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[options] +exclude-newer = "2026-07-26T16:19:54.363751556Z" +exclude-newer-span = "P3D" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "omop-db-tools" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pandas" }, + { name = "psycopg2-binary" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pandas", specifier = ">=2.2.3" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "sqlalchemy", specifier = ">=2.0.39" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=6.1.0" }, + { name = "ruff", specifier = ">=0.11.4" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] diff --git a/trust/omop-db/wait-for-postgres.sh b/trust/omop-db/wait-for-postgres.sh new file mode 100755 index 000000000..dbda7b41e --- /dev/null +++ b/trust/omop-db/wait-for-postgres.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# wait-for-postgres.sh — block until Postgres answers on localhost:. +# Bounded (default 180 x 5s = 15 min: a FIRST container boot runs the whole +# init chain including the multi-GB vocabulary load before the server accepts +# TCP connections; override via WAIT_FOR_POSTGRES_ATTEMPTS). Strict about its +# argument: an empty port would make pg_isready fall back to 5432 and report +# readiness of an unrelated server. + +set -e + +max_attempts="${WAIT_FOR_POSTGRES_ATTEMPTS:-180}" + +if [ -z "${1:-}" ]; then + >&2 echo "❌ wait-for-postgres.sh: no port given (is OMOP_DB_PORT_TRUST_ set in .env.build?)" + exit 1 +fi +if ! command -v pg_isready >/dev/null 2>&1; then + >&2 echo "❌ pg_isready not found — install postgresql-client" + exit 1 +fi + +attempts=0 +>&2 echo "⏳ Waiting for Postgres to be available on port $1 (first init loads the vocabulary — can take minutes)..." +until pg_isready -h localhost -p "$1" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge "$max_attempts" ]; then + >&2 echo "❌ Postgres not ready on port $1 after $attempts attempts — giving up" + exit 1 + fi + >&2 echo " ... still waiting on port $1 (attempt $attempts/$max_attempts)" + sleep 5 +done + +>&2 echo "✅ Postgres is up" diff --git a/trust/trust-api/CONTRIBUTING.md b/trust/trust-api/CONTRIBUTING.md index 22ebfd4d1..ae3308067 100644 --- a/trust/trust-api/CONTRIBUTING.md +++ b/trust/trust-api/CONTRIBUTING.md @@ -51,7 +51,7 @@ These can be used with the Swagger UI at `http://localhost:/docs "project_id": "my_project", "query_id": "1", "query_name": "my_query", - "query": "SELECT * FROM omop.radiology_occurrence", + "query": "SELECT * FROM omop.image_occurrence", "trust_id": "mock" } ``` @@ -63,7 +63,7 @@ These can be used with the Swagger UI at `http://localhost:/docs "project_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "trust_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "project_name": "my_project", - "query": "SELECT * FROM omop.radiology_occurrence", + "query": "SELECT * FROM omop.image_occurrence", "users": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", @@ -78,5 +78,5 @@ These can be used with the Swagger UI at `http://localhost:/docs ``` project: test -query: SELECT * FROM omop.radiology_occurrence +query: SELECT * FROM omop.image_occurrence ```