diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf941a..5961063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,5 +6,29 @@ on: jobs: label-gate: uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main - build-test: + build-test-sqlite: uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main + build-test-postgres: + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres-v2.yml@main + with: + postgres-db: omop_graph_test + setup-commands: | + uv run omop-config configure omop_graph \ + --set cdm_db.kind=cdm \ + --set cdm_db.connection.dialect=postgresql+psycopg \ + --set cdm_db.connection.host=localhost \ + --set cdm_db.connection.port=5432 \ + --set cdm_db.connection.user=test \ + --set cdm_db.connection.password=test \ + --set cdm_db.connection.database_name=omop_graph_ci_placeholder \ + --set cdm_db.connection.test_only=false \ + --set cdm_db.cdm_schema=public \ + --set test_cdm_db_pg.kind=cdm \ + --set test_cdm_db_pg.connection.dialect=postgresql+psycopg \ + --set test_cdm_db_pg.connection.host=localhost \ + --set test_cdm_db_pg.connection.port=5432 \ + --set test_cdm_db_pg.connection.user=test \ + --set test_cdm_db_pg.connection.password=test \ + --set test_cdm_db_pg.connection.database_name=omop_graph_test \ + --set test_cdm_db_pg.connection.test_only=true \ + --set test_cdm_db_pg.cdm_schema=public diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 208b49a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM python:3.12-slim -RUN pip install --no-cache-dir ".[postgres,emb,pgvector,faiss-cpu]" -WORKDIR /workspace diff --git a/README.md b/README.md index a8cea59..e31e2e6 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,14 @@ For larger deployments use `[pgvector]` or `[faiss-cpu]` instead (or in addition ### KnowledgeGraph -`KnowledgeGraph` is the main entry point. It wraps a SQLAlchemy `Engine` connected to an OMOP vocabulary schema and provides a high-level Pythonic API over the relational tables. +`KnowledgeGraph` is the main entry point. It takes a factory of sessions on the OMOP CDM and provides a high-level Pythonic API over the relational tables. ```python -from sqlalchemy import create_engine +from omop_graph.db.session import cdm_session_factory from omop_graph.graph.kg import KnowledgeGraph from omop_graph.graph.nodes import LabelMatchKind -engine = create_engine("postgresql://user:pass@localhost/omop") -kg = KnowledgeGraph(cdm_engine=engine) +kg = KnowledgeGraph(cdm_session_factory()) # the configured cdm_db # Lookup a concept by label matches = kg.concept_lookup("Atrial Fibrillation", match_kind=LabelMatchKind.EXACT) diff --git a/docs/graph/kg.md b/docs/graph/kg.md index 297add2..dfd0d82 100644 --- a/docs/graph/kg.md +++ b/docs/graph/kg.md @@ -27,15 +27,14 @@ While the OMOP CDM is stored in a Relational Database Management System (RDBMS), ### Basic Usage -The `KnowledgeGraph` can be used standalone after connecting to the OMOP CDM database. +The `KnowledgeGraph` takes a factory of sessions on the OMOP CDM. `cdm_session_factory()` builds one for the configured database; to build it yourself, pass the pair from `create_engines()` to `omop_alchemy.cross_database.cdm_sessionmaker`. Its sessions send each table to the database hosting it, so the graph works the same when the vocabulary lives on its own server. ```python -from sqlalchemy import create_engine +from omop_graph.db.session import cdm_session_factory from omop_graph.graph.kg import KnowledgeGraph from omop_graph.graph.nodes import LabelMatchKind -engine = create_engine("postgresql://user:pass@localhost/omop") -kg = KnowledgeGraph(cdm_engine=engine) +kg = KnowledgeGraph(cdm_session_factory()) # the configured cdm_db # Lookup a concept by its label matches = kg.concept_lookup("Atrial Fibrillation", match_kind=LabelMatchKind.EXACT) @@ -60,18 +59,16 @@ This requires the optional `omop-emb` package — see the [installation guide](. `KnowledgeGraphEmbeddingConfiguration` is a complete configuration: `backend` (an already-constructed `omop_emb.EmbeddingBackend`) and `resolved_model` (an `oa_configurator.ResolvedModel`) are both required fields, not Optional. omop-graph never resolves either itself: the caller (the actual CLI/entry-point boundary, e.g. omop-spires) resolves the vector store and the model, builds the backend, and passes both in here. `model_name`/`provider_type` are plain properties reading straight off `resolved_model` (`.model`/`.provider.provider`); there's nothing to pass for them separately. ```python -from sqlalchemy import create_engine from oa_configurator import Resolver -from omop_emb.backends import resolve_backend_from_resolved_vector_store +from omop_emb.backends import open_vector_store_writer +from omop_graph.db.session import cdm_session_factory from omop_graph.graph.kg import KnowledgeGraph, KnowledgeGraphEmbeddingConfiguration from omop_emb.config import MetricType -engine = create_engine("postgresql://user:pass@localhost/omop") - resolver = Resolver.from_active_config() resolved_vector_store = resolver.resolve_vector_store("vector_store") # a [vector_stores.*] entry name resolved_model = resolver.resolve_model("embedding-model") # a [models.*] entry name -backend = resolve_backend_from_resolved_vector_store(resolved_vector_store) +backend = open_vector_store_writer(resolved_vector_store) emb_config = KnowledgeGraphEmbeddingConfiguration( metric_type=MetricType.COSINE, @@ -79,7 +76,7 @@ emb_config = KnowledgeGraphEmbeddingConfiguration( resolved_model=resolved_model, # write defaults to False ) -kg = KnowledgeGraph(cdm_engine=engine, emb_config=emb_config) +kg = KnowledgeGraph(cdm_session_factory(), emb_config=emb_config) ``` See [omop-llm: Asymmetric Embeddings](https://AustralianCancerDataNetwork.github.io/omop-llm/usage/asymmetric-embeddings/) and @@ -97,7 +94,7 @@ emb_config = KnowledgeGraphEmbeddingConfiguration( resolved_model=resolved_model, write=True, ) -kg = KnowledgeGraph(cdm_engine=engine, emb_config=emb_config) +kg = KnowledgeGraph(cdm_session_factory(), emb_config=emb_config) ``` `faiss_cache_dir` (optional `str`) is read-only-path-only: a directory to cache FAISS index files, passed straight through to `EmbeddingReaderInterface`. @@ -119,11 +116,11 @@ emb_config = KnowledgeGraphEmbeddingConfiguration( write=True, compute_missing_embeddings=True, ) -kg = KnowledgeGraph(cdm_engine=engine, emb_config=emb_config) +kg = KnowledgeGraph(cdm_session_factory(), emb_config=emb_config) ``` | `compute_missing_embeddings` | `write` | Behaviour | |---|---|---| | `True` | `False` | `ValueError` at construction as it is invalid combination | | `False` | any | Log at INFO and skip missing concepts in scoring | -| `True` | `True` | Compute embeddings, persist to DB, then score | \ No newline at end of file +| `True` | `True` | Compute embeddings, persist to DB, then score | diff --git a/docs/oaklib/interface.md b/docs/oaklib/interface.md index 918cb9a..6d3efbe 100644 --- a/docs/oaklib/interface.md +++ b/docs/oaklib/interface.md @@ -29,31 +29,42 @@ The primary adapter class that inherits from multiple OAK interfaces: * **`TextAnnotatorInterface`**: Provides a pipeline to ground raw text spans to OMOP Concept IDs using the internal `KnowledgeGraph` resolvers. ### Resource Management -To initialize a connection, `omop-graph` uses a specialized resource factory: +The adapter resolves its database through oa-configurator; there is no URL-based construction. -* **`OMOPOntologyResource`**: A dataclass that wraps the SQLAlchemy connection URL, treating the database as a live ontology source. -* **`omop_resource()`**: A factory function that resolves database credentials from an explicit URL, or from the active oa-configurator stack config (`OmopGraphConfig.cdm_db`) when no URL is given. +* **`OMOPOntologyResource`**: The oaklib resource for the `omop` scheme. Its `slug` names a `[databases.*]` entry; no slug resolves `OmopGraphConfig.cdm_db`. +* **`resolved=`**: Pass an already-resolved `ResolvedCDMDatabase` to build the engines from it directly. A separate `vocab_connection` produces a second, vocabulary-only engine. +* **`kg=`**: Pass an existing `KnowledgeGraph` to use its engines as-is. --- ## Usage Examples ### Initializing the Adapter -You can get an OAK-compliant adapter by providing a SQLAlchemy connection string: +Select the adapter through oaklib with an oa-configurator database name: ```python -from omop_graph.oaklib_interface import OMOPAlchemyImplementation +from oaklib import get_adapter -# Initialize via connection string -adapter = OMOPAlchemyImplementation( - engine_string="postgresql://user:pass@localhost/omop" -) +# Named [databases.*] entry +adapter = get_adapter("omop:cdm_db") + +# Or OmopGraphConfig.cdm_db +adapter = get_adapter("omop:") # Use standard OAK methods label = adapter.label("OMOP:44819488") print(f"Label: {label}") ``` +Or construct it from an already-resolved database: + +```python +from omop_graph.db.session import resolve_cdm_database +from omop_graph.oaklib_interface.omop_implementation import OMOPAlchemyImplementation + +adapter = OMOPAlchemyImplementation(resolved=resolve_cdm_database("cdm_db")) +``` + ### Searching and Traversal Since the adapter implements the `SearchInterface`, you can perform standardized searches: diff --git a/docs/reasoning/grounding.md b/docs/reasoning/grounding.md index 19d6ba9..591586d 100644 --- a/docs/reasoning/grounding.md +++ b/docs/reasoning/grounding.md @@ -43,7 +43,7 @@ To accelerate the grounding to standard concepts, `omop-graph` makes use of: | `parent_ids` | `tuple[int, ...]` | `None` | Only accept candidates that are descendants of these OMOP concept IDs (hierarchy validation via `concept_ancestor`). | | `search_constraint` | `ConceptFilter` | `None` | Filters applied to the initial resolver query (concept IDs, domain, vocabulary, standard/active flags, and limit). | | `max_depth` | `int` | `6` | Maximum hop distance allowed between a candidate and its standard anchor. | -| `predicate_kinds` | `frozenset[PredicateKind]` | `{IDENTITY}` | Relationship kinds followed when walking from a non-standard candidate to its standard anchor. | +| `predicate_kinds` | `frozenset[PredicateKind]` | `{IDENTITY}` | Relationship kinds followed when walking from a non-standard candidate to its standard anchor. Currently locked: `__post_init__` raises `ValueError` for any value other than exactly `frozenset({PredicateKind.IDENTITY})` — not yet configurable in practice, despite being a normal dataclass field. | ### ConceptFilter @@ -99,15 +99,15 @@ TotalScore = Relevance - ParsimonyPenalty + BroadnessBonus $$ #### 1. Relevance -Relevance represents the initial semantic fit and is computed as **either** embedding similarity **or** textual similarity — not both simultaneously: +Relevance represents the initial semantic fit and is computed as **either** embedding similarity **or** textual similarity, chosen per candidate rather than globally — not both simultaneously for the same candidate: -- **Without embeddings**: textual similarity is used exclusively. -- **With embeddings** (default when `omop-graph[emb]` is installed and configured): embedding cosine similarity **replaces** the textual score entirely. +- A candidate resolved by `EmbeddingResolver` (`match_kind == LabelMatchKind.EMBEDDING`) gets embedding cosine similarity. +- Every other candidate — resolved via exact/partial/full-text matching — always gets textual similarity, whether or not `omop-graph[emb]` is installed. The two scoring modes: -- **Embedding Similarity**: Cosine similarity between the input text embedding and the concept embedding. Requires `omop-graph[emb]` and a configured `KnowledgeGraphEmbeddingConfiguration` — see the [Knowledge Graph docs](../graph/kg.md#embedding-configuration) and the [omop-emb documentation](https://australiancancerdatanetwork.github.io/omop-emb/) for setup. -- **Textual Similarity**: A custom token-overlap score that heavily penalizes missing words from the user's query but allows for "extra" descriptive words in the OMOP concept name. Used as a fallback when no embedding is available. +- **Embedding Similarity**: Cosine similarity between the input text embedding and the concept embedding. Only applies to candidates resolved via `EmbeddingResolver`, which requires `omop-graph[emb]` and a configured `KnowledgeGraphEmbeddingConfiguration` — see the [Knowledge Graph docs](../graph/kg.md#embedding-configuration), [Resolver Pipelines](resolvers.md), and the [omop-emb documentation](https://australiancancerdatanetwork.github.io/omop-emb/) for setup. +- **Textual Similarity**: A custom token-overlap score that heavily penalizes missing words from the user's query but allows for "extra" descriptive words in the OMOP concept name. Used for every candidate not resolved via embeddings. #### 2. Parsimony: Distance Penalty OMOP is a deep hierarchy. A concept that is 1 hop away from your search term is more likely to be correct than one found 5 hops away. diff --git a/docs/reasoning/resolvers.md b/docs/reasoning/resolvers.md index 0703dc4..285c1eb 100644 --- a/docs/reasoning/resolvers.md +++ b/docs/reasoning/resolvers.md @@ -6,14 +6,15 @@ The backbone of the `ResolverPipeline` are specific **resolvers**. `omop-graph` - **`ExactLabelResolver`**: Exact case-insensitive match. Is there anywhere in the the [`concept`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept) table the exact string `"Hodgkin lymphoma"` - **`ExactSynonymResolver`**: Similar to `ExactLabelResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table -- **`FullTextResolver`**: Matches irrespective of word order. Not as relevant for the example above but relevant for others (e.g., "Kidney Cancer" -> "Cancer of Kidney"). -- **`FullTextSynonymResolver`**: Similar to `FullTextResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table - **`PartialLabelResolver`**: Substring match. Is the search string `"Hodgkin lymphoma"` a partial component of any [`concept`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept)? - **`PartialSynonymResolver`**: Similar to `PartialLabelResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table +- **`FullTextResolver`**: Matches irrespective of word order. Not as relevant for the example above but relevant for others (e.g., "Kidney Cancer" -> "Cancer of Kidney"). +- **`FullTextSynonymResolver`**: Similar to `FullTextResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table +- **`EmbeddingResolver`**: Vector-similarity match, appended to the pipeline only when `omop-graph[emb]` (`omop-emb`) is installed. !!! tip - Traversing each of the resolvers one by one can be an exhaustive search. The `ResolverPipeline` therefore offers a `stop_after_resolver` option. If set, retrieval from the DB stops after that resolver has concluded. The resolvers are ordered based on their confidence as above (i.e. **`ExactLabelResolver`** >> **`ExactSynonymResolver`** >> etc.) + Traversing each of the resolvers one by one can be an exhaustive search. The `ResolverPipeline` therefore offers a `stop_after_resolver` option. If set, retrieval from the DB stops after that resolver has concluded. `ALL_RESOLVERS`, the default sequence, is ordered exactly as listed above (i.e. **`ExactLabelResolver`** >> **`ExactSynonymResolver`** >> **`PartialLabelResolver`** >> ... >> **`EmbeddingResolver`**). ```python from omop_alchemy.cdm.query import ConceptFilter diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 2080cc5..7b0a198 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -57,5 +57,5 @@ omop-graph relationship-classification --pred-class-dir | Option | Short | Type | Default | Description | | :--- | :--- | :--- | :--- | :--- | -| **`--pred-class-dir`** | | `String` | **Required** | Path to the directory containing the classification CSVs. | -| **`--verbose`** | `-v` | `Count` | `0` | Increase logging verbosity (use `-v` or `-vv`). | +| **`--pred-class-dir`** | | `String` | `None` (bundled CSVs) | Path to a directory of classification CSVs, overriding the bundled defaults. | +| **`--verbose`**{: title="Global option, not specific to this subcommand — see the note above." } | `-v` | `Count` | `0` | Increase logging verbosity (use `-v` or `-vv`). Global option; must precede the subcommand name (see note above). | diff --git a/docs/usage/testing.md b/docs/usage/testing.md index e750ca2..803f3a6 100644 --- a/docs/usage/testing.md +++ b/docs/usage/testing.md @@ -12,22 +12,39 @@ The test runner configuration lives in `pytest.toml` at the repository root: ## What Is Currently Tested -The full suite runs against an **in-memory SQLite mock CDM** (`tests/fixtures/mock_cdm.py`'s `mock_cdm_engine` fixture): no real database, no oa-configurator setup, and no environment variables are needed to run it. Current coverage is centered on functional behavior and integration-style graph operations, including: +The default suite runs graph queries against an **in-memory SQLite mock CDM** (`tests/fixtures/mock_cdm.py`'s `mock_cdm_engine_sqlite` fixture, used by `mock_cdm_kg`). PostgreSQL-only integration tests use the separate `mock_cdm_engine` fixture and oa-configurator's isolated test database fixture. Current SQLite coverage includes: - Grounding behavior from text inputs to expected OMOP concept IDs - Rendering behavior for text and Mermaid output under `tests/render` - Optional full-text behavior guards when sidecar full-text metadata/columns are not present +- KnowledgeGraph full-text lookup returning no matches when SQLite does not support full-text search The grounding test suite is structured with parametrized cases so each clinical term is a separate pytest case for easier isolation and debugging. +### PostgreSQL-only integration suite + +A separate suite, tagged with the `db_dialect` marker and excluded from the +default run (`pytest.toml`'s `-m "not db_dialect"`), runs against a real +PostgreSQL database via oa-configurator's test infrastructure. This is where +schema-drift protection and split-connection behavior are covered: +`test_schema_provenance_guard.py`, `test_vocab_split_connection.py`, +`test_oaklib_schema_awareness.py`, `test_fulltext_vocab_schema_postgres.py`, +`test_predicate_flags.py`, `test_relationship_classification.py`. + ## Running Tests -Run all tests: +Run all tests (SQLite suite only, the default): ```bash pytest ``` +Include the PostgreSQL-only suite: + +```bash +pytest -m db_dialect +``` + Run one file: ```bash diff --git a/pyproject.toml b/pyproject.toml index 3b6693d..120e360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,27 +33,27 @@ classifiers = [ ] dependencies = [ - "omop-alchemy>=1.0.0,<2.0.0", - "orm-loader>=1.0.0,<2.0.0", + "omop-alchemy>=2.0.0,<3", + "orm-loader>=2.0.0,<3", "typing-extensions>=4.15.0", "typer>=0.12", "rich>=13.0", "oaklib", - "oa-configurator>=1.0.0,<2.0.0" + "oa-configurator>=2.0.0,<3" ] [project.optional-dependencies] emb = [ - "omop-emb>=2.0.0,<3.0.0", + "omop-emb>=3.0.0,<4", ] faiss-cpu = [ - "omop-emb[faiss-cpu]>=2.0.0,<3.0.0", + "omop-emb[faiss-cpu]>=3.0.0,<4", ] pgvector = [ - "omop-emb[pgvector]>=2.0.0,<3.0.0", + "omop-emb[pgvector]>=3.0.0,<4", ] postgres = [ - "omop-alchemy[postgres]>=1.0.0,<2.0.0", + "omop-alchemy[postgres]>=2.0.0,<3", ] @@ -71,7 +71,7 @@ dev = [ "mkdocs-mermaid2-plugin", "mkdocs-table-reader-plugin", "mkdocs-macros-plugin", - "oa-configurator[postgres]>=1.0.0,<2.0.0", + "oa-configurator[postgres]>=2.0.0,<3", ] [project.scripts] @@ -80,6 +80,9 @@ omop-graph = "omop_graph.cli:app" [project.entry-points."omop.config"] omop_graph = "omop_graph.config:OmopGraphConfig" +[project.entry-points."oaklib.plugins"] +omop = "omop_graph.oaklib_interface.omop_implementation:OMOPAlchemyImplementation" + [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" diff --git a/pytest.toml b/pytest.toml index b2320fb..ba0e085 100644 --- a/pytest.toml +++ b/pytest.toml @@ -1,6 +1,6 @@ [pytest] testpaths = ["tests"] -addopts = ["-rf", "-rx", "--disable-pytest-warnings"] +addopts = ["-rf", "-rx", "--disable-pytest-warnings", "-m", "not db_dialect"] log_cli = true log_cli_level = "DEBUG" log_cli_format = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" diff --git a/scripts/benchmarks/benchmark.py b/scripts/benchmarks/benchmark.py index 819dab2..748a6d4 100644 --- a/scripts/benchmarks/benchmark.py +++ b/scripts/benchmarks/benchmark.py @@ -13,9 +13,6 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, cast, Annotated import typer -import sqlalchemy as sa -from sqlalchemy.orm import sessionmaker - import numpy as np from oa_configurator import Resolver, ResolvedModel, ResolvedProvider from omop_emb.config import ( @@ -25,7 +22,7 @@ parse_metric_type, ) from omop_emb.interface import EmbeddingRole -from omop_emb.backends import EmbeddingBackend, resolve_backend_from_resolved_vector_store +from omop_emb.backends import EmbeddingBackend, open_vector_store_writer from omop_emb.backends.index_config import index_config_from_index_type from omop_graph.config import OmopGraphConfig from omop_graph.extensions.emb import get_embedding_writer_interface, MissingExtensionError @@ -45,7 +42,7 @@ PartialLabelResolver, PartialSynonymResolver, ) -from omop_graph.db.session import make_engine +from omop_graph.db.session import cdm_session_factory app = typer.Typer() @@ -163,21 +160,6 @@ def load_cases(path: Path) -> List[BenchmarkCase]: raise TypeError(f"Unsupported benchmark case file shape: {type(payload).__name__}") -def build_session_factory() -> sessionmaker: - """Build a SQLAlchemy session factory via oa-configurator.""" - return sessionmaker(bind=make_engine(), future=True) - - -def build_engine() -> sa.Engine: - """Build a SQLAlchemy engine via oa-configurator.""" - return make_engine() - - -def build_knowledge_graph() -> KnowledgeGraph: - """Create a KnowledgeGraph backed by the live OMOP CDM database.""" - return KnowledgeGraph(cdm_engine=make_engine()) - - def build_embedding_knowledge_graph( embedding_metric: MetricType, resolved_model: ResolvedModel, @@ -185,7 +167,7 @@ def build_embedding_knowledge_graph( ) -> KnowledgeGraph: """Create a KnowledgeGraph with embedding support configured.""" - cdm_engine = make_engine() + cdm_sessions = cdm_session_factory() config = KnowledgeGraphEmbeddingConfiguration( metric_type=embedding_metric, backend=backend, @@ -194,7 +176,7 @@ def build_embedding_knowledge_graph( compute_missing_embeddings=True, ) return KnowledgeGraph( - cdm_engine=cdm_engine, + cdm_sessions, emb_config=config ) @@ -592,7 +574,7 @@ def run_benchmark( "via `omop-config configure omop_graph`." ) resolved_vector_store = Resolver.from_active_config().resolve_vector_store(resolved_name) - embedding_backend = resolve_backend_from_resolved_vector_store(resolved_vector_store) + embedding_backend = open_vector_store_writer(resolved_vector_store) embedding_kg = build_embedding_knowledge_graph( embedding_metric=resolved_embedding_metric_type, @@ -616,7 +598,7 @@ def run_benchmark( for case in cases } - kg = build_knowledge_graph() + kg = KnowledgeGraph(cdm_session_factory()) configs = build_grounded_configs() errors: Dict[str, str] = {} diff --git a/scripts/benchmarks/enrich_gold_standard.py b/scripts/benchmarks/enrich_gold_standard.py index fd08df8..c418cc8 100644 --- a/scripts/benchmarks/enrich_gold_standard.py +++ b/scripts/benchmarks/enrich_gold_standard.py @@ -26,29 +26,34 @@ from typing import Dict, List from dataclasses import dataclass -from sqlalchemy import text - -from omop_graph.db.session import make_engine - -DOMAIN_QUERY = text( - """ - SELECT concept_id, domain_id - FROM omop.concept - WHERE concept_id = ANY(:ids) - """ -) - -ANCESTOR_QUERY = text( - """ - SELECT ca.descendant_concept_id, ca.ancestor_concept_id, ca.min_levels_of_separation, - c.concept_name, c.domain_id - FROM omop.concept_ancestor ca - JOIN omop.concept c ON c.concept_id = ca.ancestor_concept_id - WHERE ca.descendant_concept_id = ANY(:ids) - AND ca.min_levels_of_separation > 0 - AND c.standard_concept = 'S' - """ -) +import sqlalchemy as sa +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Ancestor + +from omop_graph.db.session import cdm_session_factory + + +def _domain_query(ids: List[int]) -> sa.Select: + """Each target concept's domain.""" + return sa.select(Concept.concept_id, Concept.domain_id).where(Concept.concept_id.in_(ids)) + + +def _ancestor_query(ids: List[int]) -> sa.Select: + """Standard strict ancestors of each target, with their name and domain.""" + return ( + sa.select( + Concept_Ancestor.descendant_concept_id, + Concept_Ancestor.ancestor_concept_id, + Concept_Ancestor.min_levels_of_separation, + Concept.concept_name, + Concept.domain_id, + ) + .join(Concept, Concept.concept_id == Concept_Ancestor.ancestor_concept_id) + .where( + Concept_Ancestor.descendant_concept_id.in_(ids), + Concept_Ancestor.min_levels_of_separation > 0, + Concept.standard_concept == "S", + ) + ) @dataclass class LevelStats: @@ -81,17 +86,17 @@ def __iadd__(self, other: "Stats") -> "Stats": return self -def enrich_cases(engine, cases: List[Dict], levels: List[int]) -> Stats: +def enrich_cases(cdm_sessions, cases: List[Dict], levels: List[int]) -> Stats: target_ids = sorted({c["expected_concept_id"] for c in cases if c.get("expected_concept_id")}) - with engine.connect() as conn: + with cdm_sessions() as session: domain_by_target = { row.concept_id: row.domain_id - for row in conn.execute(DOMAIN_QUERY, {"ids": target_ids}).fetchall() + for row in session.execute(_domain_query(target_ids)).all() } ancestors_by_target: Dict[int, List] = defaultdict(list) - for row in conn.execute(ANCESTOR_QUERY, {"ids": target_ids}).fetchall(): + for row in session.execute(_ancestor_query(target_ids)).all(): ancestors_by_target[row.descendant_concept_id].append(row) stats: Stats = Stats(levels={level: LevelStats() for level in levels}, no_target=0) @@ -148,11 +153,11 @@ def main() -> None: levels = [int(x) for x in args.levels.split(",")] payload = json.loads(args.cases_file.read_text()) - engine = make_engine() + cdm_sessions = cdm_session_factory() total_stats = Stats.empty_for(levels) for bucket_name, cases in payload.items(): - stats = enrich_cases(engine, cases, levels) + stats = enrich_cases(cdm_sessions, cases, levels) print(f"[{bucket_name}] {stats}") total_stats += stats diff --git a/scripts/benchmarks/trace_example.py b/scripts/benchmarks/trace_example.py index 56b1568..33a7da7 100644 --- a/scripts/benchmarks/trace_example.py +++ b/scripts/benchmarks/trace_example.py @@ -32,7 +32,7 @@ ) from omop_graph.config import OmopGraphConfig -from omop_graph.db.session import make_engine +from omop_graph.db.session import cdm_session_factory from omop_graph.extensions.emb import get_embedding_writer_interface from omop_graph.extensions.omop_alchemy import PredicateKind from omop_alchemy.cdm.query import ConceptFilter @@ -101,10 +101,10 @@ def _main( def _build_kg(metric_type=None, embedding_model: Optional[str] = None) -> KnowledgeGraph: """Build a KG with embedding support, resolved from OmopGraphConfig.""" - cdm_engine = make_engine() + cdm_sessions = cdm_session_factory() try: from oa_configurator import Resolver - from omop_emb.backends import resolve_backend_from_resolved_vector_store + from omop_emb.backends import open_vector_store_writer from omop_emb.config import MetricType resolved_metric = metric_type if metric_type is not None else MetricType.COSINE @@ -118,7 +118,7 @@ def _build_kg(metric_type=None, embedding_model: Optional[str] = None) -> Knowle resolver = Resolver.from_active_config() resolved_model = resolver.resolve_model(resolved_model_name) resolved_vector_store = resolver.resolve_vector_store(cfg.vector_store_name) - backend = resolve_backend_from_resolved_vector_store(resolved_vector_store) + backend = open_vector_store_writer(resolved_vector_store) emb_config = KnowledgeGraphEmbeddingConfiguration( metric_type=resolved_metric, @@ -127,10 +127,10 @@ def _build_kg(metric_type=None, embedding_model: Optional[str] = None) -> Knowle write=True, ) logger.info("Embedding config loaded (model=%s, metric=%s).", resolved_model.model, resolved_metric.value) - return KnowledgeGraph(cdm_engine=cdm_engine, emb_config=emb_config) + return KnowledgeGraph(cdm_sessions, emb_config=emb_config) except Exception as exc: logger.warning("Error occurred while loading embedding config:\n%s.\nRunning without embedding.", exc) - return KnowledgeGraph(cdm_engine=cdm_engine) + return KnowledgeGraph(cdm_sessions) def _resolver_label(resolver) -> str: @@ -1719,7 +1719,7 @@ def panel_svg( else: selected = cases - kg = KnowledgeGraph(cdm_engine=make_engine()) + kg = KnowledgeGraph(cdm_session_factory()) rel_class_cache: Dict[int, Dict] = {} out_dir = trace_dir_pl / "plots" / "panel" @@ -1782,7 +1782,7 @@ def graph_svg( else: selected = cases - kg = KnowledgeGraph(cdm_engine=make_engine()) + kg = KnowledgeGraph(cdm_session_factory()) out_dir = trace_dir_pl / "plots" / "graph" out_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index c7a2038..9684482 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -7,22 +7,54 @@ import pandas as pd import sqlalchemy as sa import typer -from sqlalchemy.orm import sessionmaker +import sqlalchemy.orm as so -from orm_loader.backends import resolve_backend +from oa_configurator import ResolvedCDMDatabase, open_connection + +from orm_loader.backends import STAGING_SCHEMA, resolve_backend from orm_loader.helpers import bulk_load_context from orm_loader.helpers.metadata import Base from orm_loader.loaders.loader_interface import PandasLoader +from omop_alchemy.cdm.model.vocabulary.relationship import Relationship from omop_graph.config import OmopGraphConfig -from omop_graph.db.session import make_engine -from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping +from omop_graph.db.session import resolve_cdm_database, open_cdm_sessions +from omop_graph.extensions.omop_alchemy import ( + RelationshipClass, + RelationshipMapping, + create_extension_tables, +) from omop_graph.cli_utils import populate_test_data app = typer.Typer() logger = logging.getLogger(__name__) +def _filter_unknown_relationship_ids( + mapping: pd.DataFrame, vocabulary_ids: set[str] +) -> pd.DataFrame: + """Drop and report relationship mappings absent from the CDM vocabulary.""" + if not vocabulary_ids: + raise RuntimeError( + "The CDM relationship vocabulary is empty; load vocabulary data " + "before running `relationship-classification`." + ) + unknown_ids = sorted(set(mapping["relationship_id"].dropna()) - vocabulary_ids) + if unknown_ids: + logger.warning( + "Dropping %d relationships not found in the CDM vocabulary: %s", + len(unknown_ids), + unknown_ids, + ) + mapping = mapping[mapping["relationship_id"].isin(vocabulary_ids)] + if mapping.empty: + raise RuntimeError( + "None of the relationship mappings match IDs in the CDM vocabulary; " + "no relationship classifications were loaded." + ) + return mapping + + @app.callback() def _main( verbose: Annotated[ @@ -41,9 +73,15 @@ def _main( @app.command() def populate_with_test_data(): """Populate the database with synthetic test data.""" - engine = make_engine() - Session = sessionmaker(bind=engine, future=True) - populate_test_data(Session()) + resolved = resolve_cdm_database() + if not resolved.connection.test_only or not resolved.vocab_connection.test_only: + raise RuntimeError( + f"Refusing to populate {resolved.name!r} with synthetic test data: " + "both its primary and vocab connections must be test_only." + ) + with open_cdm_sessions(resolved) as cdm_sessions: + with cdm_sessions() as session: + populate_test_data(session) def packaged_predicate_csv_dir() -> Path: @@ -57,20 +95,48 @@ def packaged_predicate_csv_dir() -> Path: return Path(str(resources.files("omop_graph") / "data")) -@app.command() def relationship_classification( - pred_class_dir: Annotated[ - Optional[str], - typer.Option( - help=( - "Path to the directory containing `predicate_classification.csv` " - "and `predicate_mapping.csv`. Defaults to the copies shipped with " - "omop-graph; pass a directory to override them." + pred_class_dir: Optional[str] = None, + *, + resolved: ResolvedCDMDatabase | None = None, +) -> None: + """Load pre-classified predicates into the database. + + Parameters + ---------- + pred_class_dir : str, optional + Path to the directory containing `predicate_classification.csv` and + `predicate_mapping.csv`. Defaults to the copies shipped with + omop-graph. + resolved : ResolvedCDMDatabase, optional + Database to load into. Defaults to the active config's CDM database. + When the vocabulary has its own database, ``RelationshipMapping`` is + created without its FK to ``relationship.relationship_id``. + """ + if resolved is None: + resolved = resolve_cdm_database() + with open_cdm_sessions(resolved) as cdm_sessions: + with cdm_sessions() as session: + engine = session.get_bind(RelationshipMapping).engine + relationship_ids = set( + session.scalars(sa.select(Relationship.relationship_id)).all() ) - ), - ] = None, -): - """Load pre-classified predicates into the database.""" + _load_relationship_classification( + pred_class_dir, + resolved=resolved, + engine=engine, + relationship_ids=relationship_ids, + ) + + +def _load_relationship_classification( + pred_class_dir: Optional[str], + *, + resolved: ResolvedCDMDatabase, + engine: sa.Engine, + relationship_ids: set[str], +) -> None: + """Body of :func:`relationship_classification`, on *engine*, which hosts the extension tables.""" pred_class_dir_pl = ( Path(pred_class_dir) if pred_class_dir else packaged_predicate_csv_dir() ) @@ -123,6 +189,9 @@ def relationship_classification( df_rel_mapping = df_rel_mapping[ ["relationship_id", "predicate_kind", "predicate_subkind"] ].dropna(subset=["predicate_kind", "predicate_subkind"], how="all") # type: ignore[call-overload] + df_rel_mapping = _filter_unknown_relationship_ids( + df_rel_mapping, relationship_ids + ) invalid_mask = ( df_rel_mapping[["predicate_kind", "predicate_subkind"]].isna().any(axis=1) ) @@ -139,34 +208,36 @@ def relationship_classification( subset=["relationship_id", "predicate_kind", "predicate_subkind"] ) - engine = make_engine() - Session = sessionmaker(bind=engine, future=True) - session = Session() - loader_backend = resolve_backend(engine) + loader_backend = resolve_backend(engine, staging_schema_tag=STAGING_SCHEMA) - with engine.begin() as conn: - conn.execute( - sa.text( - "DROP TABLE IF EXISTS " - f"{loader_backend.qualified_staging_name(RelationshipMapping.__tablename__)} CASCADE" - ) - ) - conn.execute( - sa.text( - "DROP TABLE IF EXISTS " - f"{loader_backend.qualified_staging_name(RelationshipClass.__tablename__)} CASCADE" - ) - ) - conn.execute(sa.text("DROP TYPE IF EXISTS predicatekindenum CASCADE;")) + drop_staging_sql = ( + sa.text( + "DROP TABLE IF EXISTS " + f"{loader_backend.qualified_staging_name(RelationshipMapping.__tablename__)} CASCADE" + ), + sa.text( + "DROP TABLE IF EXISTS " + f"{loader_backend.qualified_staging_name(RelationshipClass.__tablename__)} CASCADE" + ), + ) + with open_connection(engine) as connection: + for stmt in drop_staging_sql: + connection.execute(stmt) + # DROP TYPE IF EXISTS predicatekindenum was dead code: the Enum column + # never set an explicit name=, so SQLAlchemy's generated type name is + # actually "predicatekind". drop_all(tables=[...]) already drops the + # shared Enum type exactly once, deduped, since both tables using it + # are always in the same tables= list. tables_to_drop = [ RelationshipMapping.__table__, RelationshipClass.__table__, ] - Base.metadata.drop_all(bind=engine, tables=tables_to_drop, checkfirst=True) # type: ignore - Base.metadata.create_all(bind=engine, tables=tables_to_drop) # type: ignore + with open_connection(engine) as connection: + Base.metadata.drop_all(bind=connection, tables=tables_to_drop, checkfirst=True) # type: ignore + create_extension_tables(connection, resolved=resolved) - with tempfile.TemporaryDirectory() as tmp_dir: + with tempfile.TemporaryDirectory() as tmp_dir, so.Session(engine) as session: for model, df in zip( [RelationshipClass, RelationshipMapping], [df_rel_cls_to_export, df_rel_mapping_to_export], @@ -184,9 +255,27 @@ def relationship_classification( dedupe=True, merge_strategy="replace", loader=PandasLoader(), + staging_schema_tag=STAGING_SCHEMA, ) session.commit() +@app.command(name="relationship-classification") +def relationship_classification_cmd( + pred_class_dir: Annotated[ + Optional[str], + typer.Option( + help=( + "Path to the directory containing `predicate_classification.csv` " + "and `predicate_mapping.csv`. Defaults to the copies shipped with " + "omop-graph; pass a directory to override them." + ) + ), + ] = None, +): + """Load pre-classified predicates into the database.""" + relationship_classification(pred_class_dir) + + if __name__ == "__main__": app() diff --git a/src/omop_graph/cli_utils/cli_add_test_data.py b/src/omop_graph/cli_utils/cli_add_test_data.py index 394ffee..9af3e74 100644 --- a/src/omop_graph/cli_utils/cli_add_test_data.py +++ b/src/omop_graph/cli_utils/cli_add_test_data.py @@ -8,7 +8,7 @@ from omop_alchemy.cdm.model.structural.episode import Episode from omop_alchemy.cdm.model.structural.episode_event import Episode_Event -from omop_alchemy.cdm.model.derived import Observation_Period +from omop_alchemy.cdm.model.clinical import Observation_Period from omop_alchemy.cdm.model.health_system import ( Location, Care_Site, @@ -262,7 +262,16 @@ def populate_conditions_and_modifiers( def populate_test_data(session): - """Brute force addition of test data for development/testing purposes.""" + """Brute force addition of test data for development/testing purposes. + + Parameters + ---------- + session + From ``omop_alchemy.cross_database.cdm_sessionmaker``, so the + Concept/Concept_Ancestor reads reach the vocabulary and the clinical + writes (Person, Visit_Occurrence, Condition_Occurrence, ...) reach the + primary database. + """ # Data concept_by_domain = pd.DataFrame( diff --git a/src/omop_graph/config.py b/src/omop_graph/config.py index 54f7381..42b157a 100644 --- a/src/omop_graph/config.py +++ b/src/omop_graph/config.py @@ -37,6 +37,21 @@ class OmopGraphConfig(PackageConfigBase): ) cdm_db: Annotated[str, RefTo(CDMDatabaseConfig)] = "cdm_db" + test_cdm_db_pg: Annotated[ + str | None, RefTo(CDMDatabaseConfig, is_test=True) + ] = Field( + default=None, + description="Real PostgreSQL test CDM database, for Postgres-only integration testing.", + ) + test_cdm_db_sqlite: Annotated[ + str | None, RefTo(CDMDatabaseConfig, is_test=True) + ] = Field( + default=None, + description=( + "Disposable SQLite test database; left unconfigured by design " + "(isolated_test_database(..., dialect='sqlite') provisions one automatically)." + ), + ) embedding_model_name: Annotated[str | None, RefTo(ModelConfig)] = Field( default=None, description=( diff --git a/src/omop_graph/db/session.py b/src/omop_graph/db/session.py index 387bf8c..826f088 100644 --- a/src/omop_graph/db/session.py +++ b/src/omop_graph/db/session.py @@ -1,67 +1,97 @@ -"""SQLAlchemy engine helper for the OMOP CDM database.""" +"""Resolution helper for the OMOP CDM database.""" from __future__ import annotations -from typing import Optional, Union +from typing import Optional +from collections.abc import Generator +from contextlib import contextmanager +import sqlalchemy.orm as so +from sqlalchemy.engine import Engine +from oa_configurator import ResolvedCDMDatabase, Resolver +from omop_alchemy.config import create_cdm_engines +from omop_alchemy.cross_database import CDMSession, cdm_sessionmaker +from sqlalchemy.orm import sessionmaker -from sqlalchemy import create_engine, URL, Engine -from sqlalchemy.orm import sessionmaker, Session - -from oa_configurator import Resolver from omop_graph.config import OmopGraphConfig -def make_engine( - url: Optional[Union[URL, str]] = None, - *, - engine_kwargs: Optional[dict] = None, - execution_options: Optional[dict] = None, -) -> Engine: - """Return a SQLAlchemy engine. - - When url is omitted, reads connection details from the active oa-configurator - stack config (schema translate map applied automatically). Pass url explicitly - to override. +def resolve_cdm_database(name: Optional[str] = None) -> ResolvedCDMDatabase: + """Resolve a CDM database from the active oa-configurator config. Parameters ---------- - url : URL or str, optional - SQLAlchemy database URL. If None, resolved from the active oa-configurator config. - engine_kwargs : dict, optional - Keyword arguments forwarded to ``sqlalchemy.create_engine`` in both paths. - Common keys: ``echo``, ``connect_args``, ``pool_size``. - execution_options : dict, optional - Options forwarded to ``engine.execution_options()``. In the resolver path these - are merged with the auto-generated ``schema_translate_map`` (resolver wins on - that key via ``setdefault``). + name : str, optional + Name of the ``[databases.*]`` entry. Defaults to ``OmopGraphConfig.cdm_db``. Returns ------- - Engine - A SQLAlchemy engine instance. + ResolvedCDMDatabase + + Raises + ------ + TypeError + If the entry is not a CDM database. + """ + resolver = Resolver.from_active_config() + if name is None: + name = resolver.resolve_package_config(OmopGraphConfig).cdm_db + resolved = resolver.resolve_database(name) + if not isinstance(resolved, ResolvedCDMDatabase): + raise TypeError( + f"Database {name!r} must resolve to a CDM database, got {type(resolved).__name__}" + ) + return resolved + + +def cdm_session_factory( + name: Optional[str] = None, *, resolved: ResolvedCDMDatabase | None = None +) -> sessionmaker[CDMSession]: + """Read-only routed session factory on a CDM database from the active config. + + Builds the engine pair with ``create_cdm_engines()``, checking its schema + claims without registering them. The engines live as long as the process, + so this suits scripts that run once. + + Parameters + ---------- + name : str, optional + As for :func:`resolve_cdm_database`. + """ + resolved = resolved or resolve_cdm_database(name) + return _create_cdm_sessions(resolved, register_claims=False)[0] + + +def _create_cdm_sessions( + resolved: ResolvedCDMDatabase, *, register_claims: bool +) -> tuple[sessionmaker[CDMSession], Engine, Engine]: + """Build routed sessions and their owned engine pair through omop-alchemy.""" + primary, vocab = create_cdm_engines( + resolved, register_claims=register_claims + ) + return cdm_sessionmaker(resolved, primary=primary, vocab=vocab), primary, vocab + + +@contextmanager +def open_cdm_sessions( + resolved: ResolvedCDMDatabase, *, register_claims: bool = True +) -> Generator[so.sessionmaker[CDMSession], None, None]: + """Yield a routed session factory on ``create_cdm_engines(resolved)``, disposing both engines on exit. + + Parameters + ---------- + resolved : ResolvedCDMDatabase + register_claims : bool, optional + As for :func:`create_cdm_engines`. + + Yields + ------ + sqlalchemy.orm.sessionmaker[CDMSession] """ - engine_kwargs = engine_kwargs or {} - if url is None: - resolver = Resolver.from_active_config() - db_name = resolver.resolve_package_config(OmopGraphConfig).cdm_db - database = resolver.resolve_database(db_name) - return database.create_engine(execution_options=execution_options, **engine_kwargs) - - from sqlalchemy import make_url as _make_url - - if isinstance(url, str): - url = _make_url(url) - engine = create_engine(url, **engine_kwargs) - if execution_options: - engine = engine.execution_options(**execution_options) - return engine - - -def make_session( - url: str, - *, - echo: bool = False, -) -> Session: - engine = make_engine(url, engine_kwargs={"echo": echo}) - SessionLocal = sessionmaker(bind=engine) - return SessionLocal() + cdm_sessions, primary, vocab = _create_cdm_sessions( + resolved, register_claims=register_claims + ) + try: + yield cdm_sessions + finally: + for engine in {primary, vocab}: + engine.dispose() diff --git a/src/omop_graph/extensions/emb.py b/src/omop_graph/extensions/emb.py index 32b6e69..054ccd2 100644 --- a/src/omop_graph/extensions/emb.py +++ b/src/omop_graph/extensions/emb.py @@ -209,7 +209,6 @@ def semantic_similarity( knn_filter = EmbeddingConceptFilter(concept_ids=concept_ids) missing_sc_embeddings = embedding_reader.get_concepts_without_embedding( - omop_cdm_engine=kg.cdm_engine, concept_filter=cdm_filter, ) @@ -231,7 +230,7 @@ def semantic_similarity( concept_ids=missing_concept_ids, limit=len(missing_concept_ids) ) concept_meta = fetch_cdm_concepts_for_filter( - missing_filter, cdm_engine=kg.cdm_engine + missing_filter, cdm_session_factory=kg.session_factory ) embedding_writer.embed_and_upsert_concepts( diff --git a/src/omop_graph/extensions/omop_alchemy.py b/src/omop_graph/extensions/omop_alchemy.py index c456cb5..9fdd154 100644 --- a/src/omop_graph/extensions/omop_alchemy.py +++ b/src/omop_graph/extensions/omop_alchemy.py @@ -1,11 +1,19 @@ # Extension to omop-alchemy package import sqlalchemy as sa import sqlalchemy.orm as so -from orm_loader.helpers import Base -from omop_alchemy.cdm.base import ReferenceTable, cdm_table, CDMTableBase +from orm_loader.helpers import Base, create_tables +from omop_alchemy.cdm.base import ( + ReferenceTable, + cdm_table, + CDMTableBase, + merge_table_args, + role_fk, +) +from oa_configurator import ResolvedCDMDatabase, Role from enum import Enum from dataclasses import dataclass +from typing import cast class PredicateKind(Enum): @@ -24,12 +32,17 @@ class RelationshipClass(ReferenceTable, CDMTableBase, Base): """ __tablename__ = "relationship_class" + # Must match RelationshipMapping's schema, or SQLAlchemy silently builds a second, unlinked Table object. + __table_args__ = merge_table_args({"schema": Role.PRIMARY.value}) predicate_kind: so.Mapped[PredicateKind] = so.mapped_column( sa.Enum( PredicateKind, values_callable=lambda obj: [ e.value for e in obj - ], # Use the value of the enum for storage + ], + # Must match __table_args__'s schema to ensure that schema_translate_map + # routes the enum correctly. + schema=Role.PRIMARY.value, ), primary_key=True, ) @@ -51,14 +64,18 @@ class RelationshipMapping(ReferenceTable, CDMTableBase, Base): __tablename__ = "relationship_mapping" relationship_id: so.Mapped[str] = so.mapped_column( - sa.ForeignKey("relationship.relationship_id"), primary_key=True + sa.ForeignKey(role_fk(Role.VOCAB, "relationship.relationship_id")), + primary_key=True, ) predicate_kind: so.Mapped[PredicateKind] = so.mapped_column( sa.Enum( PredicateKind, values_callable=lambda obj: [ e.value for e in obj - ], # Use the value of the enum for storage + ], + # Must match __table_args__'s schema to ensure that schema_translate_map + # routes the enum correctly. + schema=Role.PRIMARY.value, ), primary_key=True, ) @@ -66,16 +83,46 @@ class RelationshipMapping(ReferenceTable, CDMTableBase, Base): sa.String(20), primary_key=True ) - # Define the Composite Foreign Key in __table_args__ - __table_args__ = ( + # Must match RelationshipClass's schema, or SQLAlchemy silently builds a second, unlinked Table object. + __table_args__ = merge_table_args( sa.ForeignKeyConstraint( ["predicate_kind", "predicate_subkind"], [ - "relationship_class.predicate_kind", - "relationship_class.predicate_subkind", + role_fk(Role.PRIMARY, "relationship_class.predicate_kind"), + role_fk(Role.PRIMARY, "relationship_class.predicate_subkind"), ], name="fk_rel_mapping_to_rel_class", ), + {"schema": Role.PRIMARY.value}, + ) + + +def create_extension_tables( + connection: sa.Connection, + *, + resolved: ResolvedCDMDatabase, +) -> None: + """Create the extension tables as they can physically exist on *resolved*. + + Goes through ``orm_loader``'s ``create_tables``: on a split deployment the + foreign key to the vocabulary is left out, since no dialect can express a + foreign key across two databases, and every same-tag key, including the + composite key from relationship_mapping to relationship_class, is kept. + + The ORM classes keep their own ForeignKey declarations and remain what + reads and writes these tables. SQLAlchemy infers join conditions from + that in-Python metadata, not from constraints present in the database, + so relationship loading is unaffected either way. + """ + # cast: the declarative __table__ is typed FromClause, but a mapped class + # backed by a table always carries a Table here. + create_tables( + connection, + [ + cast(sa.Table, RelationshipClass.__table__), + cast(sa.Table, RelationshipMapping.__table__), + ], + resolved=resolved, ) diff --git a/src/omop_graph/graph/edges.py b/src/omop_graph/graph/edges.py index 96d97a9..638348f 100644 --- a/src/omop_graph/graph/edges.py +++ b/src/omop_graph/graph/edges.py @@ -6,13 +6,13 @@ It focuses on data definitions and classification logic, not graph traversal algorithms. -Supported Relationships ------------------------ -* **Mapping:** Semantic equivalence (e.g., source code to standard concept). -* **Versioning:** Lifecycle tracking (e.g., 'replaced by', 'is a'). -* **Ontological:** Hierarchical structure (e.g., 'is a', 'subsumes'). +Supported Relationships (``PredicateKind``) +-------------------------------------------- +* **Hierarchy:** Structural parent/child relationships (e.g., 'is a', 'subsumes'). +* **Identity:** Semantic equivalence (e.g., 'maps to', source code to standard concept). +* **Composition:** Part-whole/component relationships. +* **Association:** General, non-hierarchical relationships between concepts. * **Attribute:** Descriptive properties (e.g., 'has dose form'). -* **Metadata:** Administrative or low-semantic value connections. """ from __future__ import annotations @@ -22,7 +22,6 @@ from datetime import date from typing import TYPE_CHECKING, Optional -from sqlalchemy.engine import Row from omop_alchemy.cdm.model import normalised_flag @@ -54,9 +53,9 @@ class EdgeView: invalid_reason : str, optional The reason for invalidation (e.g., 'D' for deleted), if applicable. predicate_kind : PredicateKind - The high-level category of this predicate (e.g. Mapping, Versioning, Ontological, Attribute, Metadata). + The high-level category of this predicate (Hierarchy, Identity, Composition, Association, or Attribute). predicate_subkind : str - The more fine-grained subclass of this predicate (e.g. for Mapping: 'standard to non-standard', 'source to standard', etc.) + The more fine-grained subclass of this predicate (e.g. for Identity: 'standard to non-standard', 'source to standard', etc.) """ subject_id: int @@ -91,13 +90,6 @@ def pretty(self, kg: KnowledgeGraph) -> str: return f"{s.concept_name} -[{pred.name}]-> {o.concept_name}" - @classmethod - def from_query(cls, entry: Row) -> "EdgeView": - data = dict(entry._mapping) - if "predicate_kind" in data: - data["predicate_kind"] = PredicateKind(data["predicate_kind"]) - return cls(**data) - @dataclass(frozen=True) class Predicate: @@ -119,9 +111,9 @@ class Predicate: anc_down : bool Whether this relationship defines 'defines_ancestry' downwards (deprecated logic). predicate_kind : PredicateKind - The high-level category of this predicate (e.g. Mapping, Versioning, Ontological, Attribute, Metadata). + The high-level category of this predicate (Hierarchy, Identity, Composition, Association, or Attribute). predicate_subkind : str - The more fine-grained subclass of this predicate (e.g. for Mapping: 'standard to non-standard', 'source to standard', etc.) + The more fine-grained subclass of this predicate (e.g. for Identity: 'standard to non-standard', 'source to standard', etc.) """ relationship_id: str diff --git a/src/omop_graph/graph/kg.py b/src/omop_graph/graph/kg.py index cf1401d..a14416f 100644 --- a/src/omop_graph/graph/kg.py +++ b/src/omop_graph/graph/kg.py @@ -19,18 +19,21 @@ import re from datetime import date from collections import defaultdict -from typing import Dict, Optional, Tuple, Literal, Generator, TYPE_CHECKING +from typing import Callable, Dict, Optional, Tuple, Literal, Generator, TYPE_CHECKING from dataclasses import dataclass -from sqlalchemy import Engine -from sqlalchemy.orm import Session, sessionmaker -from omop_alchemy.backends import FullTextError +from sqlalchemy import Row +from sqlalchemy.orm import Session +from sqlalchemy.engine import Engine +from omop_alchemy.backends import BackendNotSupportedError, FullTextError +from omop_alchemy.cdm.model.vocabulary import Concept from omop_alchemy.cdm.query import ConceptFilter from oa_configurator import ResolvedModel if TYPE_CHECKING: from omop_emb import ( EmbeddingBackend, + EmbeddingStoreReader, EmbeddingWriterInterface, EmbeddingReaderInterface, ) @@ -99,9 +102,12 @@ class KnowledgeGraphEmbeddingConfiguration: The similarity/distance metric to use for embedding comparisons (e.g., cosine, euclidean). This is required to ensure that the correct type of index is used in the backend and that similarity computations are consistent. - backend : omop_emb.EmbeddingBackend - An already-constructed embedding backend, e.g. via - ``omop_emb.backends.resolve_backend_from_resolved``. + backend : omop_emb.EmbeddingBackend or omop_emb.EmbeddingStoreReader + An already-constructed embedding backend. A write-capable + ``EmbeddingBackend`` (e.g. via ``omop_emb.backends.open_vector_store_writer``) + is required when ``write=True``; the narrower read-only + ``EmbeddingStoreReader`` (via ``open_vector_store_reader``) suffices + when ``write=False``. resolved_model : oa_configurator.ResolvedModel A model resolved via ``oa_configurator.Resolver.resolve_model()``, carrying real provider connection details. ``model_name``/``provider_type`` (see below) are @@ -121,7 +127,7 @@ class KnowledgeGraphEmbeddingConfiguration: """ metric_type: EmbeddingMetricType - backend: "EmbeddingBackend" + backend: "EmbeddingBackend | EmbeddingStoreReader" resolved_model: ResolvedModel write: bool = False compute_missing_embeddings: bool = False @@ -145,6 +151,20 @@ def provider_type(self) -> str: return self.resolved_model.provider.provider +def _predicate_from_rows(ancestry_row: Row, mapping: RelationshipMappingElement) -> Predicate: + """Build a Predicate from a Relationship-ancestry row and its classification.""" + return Predicate( + relationship_id=ancestry_row.relationship_id, + name=ancestry_row.relationship_name, + reverse_id=ancestry_row.reverse_relationship_id, + is_hierarchical=bool(ancestry_row.is_hierarchical), + anc_up=bool(ancestry_row.anc_up), + anc_down=bool(ancestry_row.anc_down), + predicate_kind=mapping.predicate_kind, + predicate_subkind=mapping.predicate_subkind, + ) + + class KnowledgeGraph(GraphBackend): """ The main entry point for interacting with the OMOP Graph. @@ -154,18 +174,27 @@ class KnowledgeGraph(GraphBackend): Parameters ---------- - cdm_engine : Engine - The SQLAlchemy engine for the OMOP CDM database. + cdm_sessions : Callable[[], Session] + Opens sessions on the OMOP CDM, e.g. from + ``omop_alchemy.cross_database.cdm_sessionmaker``, which sends each + table to the database hosting it. The relationship classification + is read once at construction and applied to vocabulary rows in + Python, so no query joins the two databases. + emb_config : KnowledgeGraphEmbeddingConfiguration, optional """ def __init__( self, - cdm_engine: Engine, + cdm_sessions: Callable[[], Session], + *, emb_config: Optional[KnowledgeGraphEmbeddingConfiguration] = None, ): - self.cdm_engine = cdm_engine - self.session_factory = sessionmaker(bind=self.cdm_engine, future=True) - + if isinstance(cdm_sessions, Engine) or not callable(cdm_sessions): + raise TypeError( + "cdm_sessions must be a callable session factory, not an Engine; " + "pass cdm_session_factory() or a sessionmaker." + ) + self.session_factory = cdm_sessions try: with self.session_factory() as session: self._relationship_mapping: dict[str, RelationshipMappingElement] = ( @@ -215,12 +244,19 @@ def emb(self) -> "EmbeddingWriterInterface | EmbeddingReaderInterface": ) if self._emb_config.write: + from omop_emb import EmbeddingBackend + + if not isinstance(self._emb_config.backend, EmbeddingBackend): + raise ValueError( + "write=True requires a write-capable EmbeddingBackend, " + f"got {type(self._emb_config.backend).__name__}." + ) # Write-capable interface: the KG builds its own embedding model backend. self._emb = EmbeddingWriterInterface( backend=self._emb_config.backend, metric_type=self._emb_config.metric_type, resolved_model=self._emb_config.resolved_model, - omop_cdm_engine=self.cdm_engine, + cdm_session_factory=self.session_factory, ) else: # Read-only interface: only ever needs model identity, never live credentials. @@ -228,7 +264,7 @@ def emb(self) -> "EmbeddingWriterInterface | EmbeddingReaderInterface": model=self._emb_config.model_name, backend=self._emb_config.backend, metric_type=self._emb_config.metric_type, - omop_cdm_engine=self.cdm_engine, + cdm_session_factory=self.session_factory, provider_type=self._emb_config.provider_type, faiss_cache_dir=self._emb_config.faiss_cache_dir, ) @@ -357,28 +393,28 @@ def concept_lookup( if not input_query_term: return () - if match_kind == LabelMatchKind.EXACT: - fn = q_concept_name_match - elif match_kind == LabelMatchKind.PARTIAL: - fn = q_concept_name_ilike - elif match_kind == LabelMatchKind.FTS: - fn = functools.partial(q_concept_name_fulltext, engine=self.cdm_engine) - else: - raise ValueError(f"Unsupported search mode: {match_kind}") - try: - cn = fn( - input_query_term, - search_constraint=search_constraint, - synonym=synonym, - sort=sort, - ) - except FullTextError as e: - if match_kind == LabelMatchKind.FTS: - logger.info(e) - return () - raise - with self.session_factory() as session: + if match_kind == LabelMatchKind.EXACT: + fn = q_concept_name_match + elif match_kind == LabelMatchKind.PARTIAL: + fn = q_concept_name_ilike + elif match_kind == LabelMatchKind.FTS: + fn = functools.partial(q_concept_name_fulltext, engine=session.get_bind(Concept)) + else: + raise ValueError(f"Unsupported search mode: {match_kind}") + try: + cn = fn( + input_query_term, + search_constraint=search_constraint, + synonym=synonym, + sort=sort, + ) + except (FullTextError, BackendNotSupportedError) as e: + if match_kind == LabelMatchKind.FTS: + logger.info(e) + return () + raise + matches = tuple( LabelMatch( input_query=input_query_term, @@ -418,16 +454,7 @@ def predicate(self, relationship_id: str) -> Predicate: """ with self.session_factory() as session: row = session.execute(q_predicate_row_with_ancestry(relationship_id)).one() - return Predicate( - relationship_id=row.relationship_id, - name=row.relationship_name, - reverse_id=row.reverse_relationship_id, - is_hierarchical=bool(row.is_hierarchical), - anc_up=bool(row.anc_up), - anc_down=bool(row.anc_down), - predicate_kind=PredicateKind(row.predicate_kind), - predicate_subkind=row.predicate_subkind, - ) + return _predicate_from_rows(row, self._mapping_for(relationship_id)) def predicate_name(self, relationship_id: str) -> str: """ @@ -444,10 +471,14 @@ def predicate_kind(self, relationship_id: str) -> PredicateKind: """ Classify the predicate into a semantic kind. """ + return self._mapping_for(relationship_id).predicate_kind + + def _mapping_for(self, relationship_id: str) -> RelationshipMappingElement: + """Classification of *relationship_id*, raising AttributeError if it has none.""" item = self._relationship_mapping.get(relationship_id) if item is None: raise AttributeError(f"`{relationship_id}` not in relationship mapping.") - return item.predicate_kind + return item def predicate_kinds( self, relationship_ids: tuple[str, ...] @@ -459,7 +490,6 @@ def predicate_kinds( def relationships( self, - session: Session, subjects: tuple[int, ...] | None, predicates: tuple[str, ...] | None, objects: tuple[int, ...] | None, @@ -487,7 +517,6 @@ def relationships( """ if invert: for s, p, o in self.relationships( - session=session, subjects=objects, predicates=predicates, objects=subjects, @@ -495,14 +524,15 @@ def relationships( yield o, p, s return - for s, p, o in session.execute( - q_relationships( - subjects=subjects, - predicates=predicates, - objects=objects, - ) - ): - yield s, p, o + with self.session_factory() as session: + for s, p, o in session.execute( + q_relationships( + subjects=subjects, + predicates=predicates, + objects=objects, + ) + ): + yield s, p, o def reverse_predicate_id(self, relationship_id: str) -> Optional[str]: """ @@ -572,19 +602,39 @@ def iter_edges( on: Optional[date] = None, within_domain: bool = True, ) -> Generator[EdgeView, None, None]: + """Yield edges of *concept_ids* on *session*, classified from the relationship mapping. + + A *predicate_kinds* filter is applied in SQL as the relationship IDs + of those kinds. Edges whose relationship has no classification are + skipped. + """ + if predicate_kinds: + kind_ids = frozenset( + relationship_id + for relationship_id, mapping in self._relationship_mapping.items() + if mapping.predicate_kind in predicate_kinds + ) + predicate_ids = kind_ids & predicate_ids if predicate_ids else kind_ids + if not predicate_ids: + return stmt = q_edges( concept_ids=concept_ids, predicate_ids=predicate_ids, direction=direction, - predicate_kinds=predicate_kinds, active_only=active_only, on=on, within_domain=within_domain, ) - for row in session.execute(stmt): - yield EdgeView.from_query(row) + mapping = self._relationship_mapping.get(row.predicate_id) + if mapping is None: + continue + yield EdgeView( + **row._mapping, + predicate_kind=mapping.predicate_kind, + predicate_subkind=mapping.predicate_subkind, + ) def specificity(self, concept_id: int) -> float: """ @@ -614,7 +664,6 @@ def children(self, concept_id) -> tuple[int, ...]: def entities( self, - session: Session, domain: str | None = None, standard_only: bool = True, filter_obsoletes: bool = True, @@ -626,8 +675,9 @@ def entities( filter_obsoletes=filter_obsoletes, ) - for row in session.execute(query): - yield int(row.concept_id) + with self.session_factory() as session: + for row in session.execute(query): + yield int(row.concept_id) def roots( self, domain_id: str | None = None, vocabulary_id: str | None = None @@ -685,17 +735,9 @@ def predicates(self) -> tuple[Predicate, ...]: with self.session_factory() as session: rows = session.execute(q_all_predicates_with_ancestry()).all() return tuple( - Predicate( - relationship_id=row.relationship_id, - name=row.relationship_name, - reverse_id=row.reverse_relationship_id, - is_hierarchical=bool(row.is_hierarchical), - anc_up=bool(row.anc_up), - anc_down=bool(row.anc_down), - predicate_kind=PredicateKind(row.predicate_kind), - predicate_subkind=row.predicate_subkind, - ) + _predicate_from_rows(row, self._relationship_mapping[row.relationship_id]) for row in rows + if row.relationship_id in self._relationship_mapping ) @functools.cached_property diff --git a/src/omop_graph/graph/queries.py b/src/omop_graph/graph/queries.py index 8bb8763..f561979 100644 --- a/src/omop_graph/graph/queries.py +++ b/src/omop_graph/graph/queries.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import Optional, Tuple, Literal, Union +from typing import Optional, Tuple, Literal, Union, cast from datetime import date from sqlalchemy import ( @@ -25,17 +25,16 @@ literal, or_, select, + Connection, Engine, - inspect, - column, + Table, ) from sqlalchemy.orm import aliased from sqlalchemy.sql import Select + from omop_alchemy.backends import ( - CONCEPT_NAME_TSVECTOR_COLUMN, - CONCEPT_SYNONYM_NAME_TSVECTOR_COLUMN, - FullTextError, + resolve_backend, ) from omop_alchemy.cdm.model.vocabulary import ( Concept, @@ -46,7 +45,7 @@ ) from omop_alchemy.cdm.query import ConceptFilter -from ..extensions.omop_alchemy import RelationshipMapping, PredicateKind +from ..extensions.omop_alchemy import RelationshipMapping def _concept_has_standardness_expr(): @@ -323,18 +322,17 @@ def q_concept_name_ilike( def q_concept_name_fulltext( query_concept_name: str, *, - engine: Engine, + engine: Engine | Connection, search_constraint: Optional[ConceptFilter] = None, synonym: bool = False, sort: bool = True, ) -> Select: """ - Query for concept names using PostgreSQL full-text search via optional + Query for concept names using PostgreSQL full-text search via the pre-computed tsvector columns and GIN indices. - This query only works when the stored tsvector columns have been installed - and registered in the ORM metadata via ``omop-maint fulltext install`` and - ``omop-maint fulltext populate``. If those columns are absent, this raises + Requires the stored tsvector columns from ``omop-alchemy fulltext install`` + and ``omop-alchemy fulltext populate``. If they are absent, this raises ``FullTextError`` instead of falling back to on-demand tsvector generation. @@ -342,41 +340,26 @@ def q_concept_name_fulltext( ---------- query_concept_name : str The concept name to search for. + engine : Engine or Connection + Bindable whose schema_translate_map locates the vocabulary schema. search_constraint : ConceptFilter, optional Additional filters (domain, vocab). synonym : bool, optional Whether to search in synonyms instead of concept names. + sort : bool, optional + Whether to order by match quality. + Raises + ------ + FullTextError + If the stored tsvector column is missing. """ name_expr = ( Concept_Synonym.concept_synonym_name if synonym else Concept.concept_name ) - - inspector = inspect(engine) target_table = Concept_Synonym if synonym else Concept - target_col = ( - CONCEPT_SYNONYM_NAME_TSVECTOR_COLUMN - if synonym - else CONCEPT_NAME_TSVECTOR_COLUMN - ) stmt = q_concept_synonym() if synonym else q_concept_name() - - tsvector_col = next( - ( - c["name"] - for c in inspector.get_columns(target_table.__tablename__) - if c["name"] == target_col - ), - None, - ) - - if tsvector_col is None: - raise FullTextError( - f"Full-text search column '{target_col}' not found in table '{target_table.__tablename__}'. " - "Make sure to run 'omop-maint fulltext install' and 'omop-maint fulltext populate' to set up full-text search." - ) - - vector = column(tsvector_col) + vector = resolve_backend(engine).fulltext_vector_column(engine, cast(Table, target_table.__table__)) query = func.plainto_tsquery("english", query_concept_name) stmt = stmt.where(vector.op("@@")(query)) # Hits the GIN index instantly @@ -436,61 +419,39 @@ def q_predicate_row_with_ancestry(relationship_id: str) -> Select: Columns: relationship_id, relationship_name, reverse_relationship_id, is_hierarchical, anc_down, anc_up. """ - Rel = Relationship - Rev = aliased(Relationship) - Rm = aliased(RelationshipMapping) - - return ( - select( - Rel.relationship_id, - Rel.relationship_name, - Rel.reverse_relationship_id, - Rel.is_hierarchical_relationship_expr().label("is_hierarchical"), - Rel.is_ancestry_defining_expr().label("anc_down"), - Rev.is_ancestry_defining_expr().label("anc_up"), - Rm.predicate_kind, - Rm.predicate_subkind, - ) - .join( - Rev, - Rel.reverse_relationship_id == Rev.relationship_id, - ) - .join(Rm, Rel.relationship_id == Rm.relationship_id) # Match string IDs - .where(Rel.relationship_id == relationship_id) - ) + return q_all_predicates_with_ancestry().where(Relationship.relationship_id == relationship_id) def q_all_predicates_with_ancestry() -> Select: - """Query all predicates with derived ancestry direction flags and classification.""" + """Query all predicates with derived ancestry direction flags.""" Rel = Relationship Rev = aliased(Relationship) - Rm = aliased(RelationshipMapping) - return ( - select( - Rel.relationship_id, - Rel.relationship_name, - Rel.reverse_relationship_id, - Rel.is_hierarchical_relationship_expr().label("is_hierarchical"), - Rel.is_ancestry_defining_expr().label("anc_down"), - Rev.is_ancestry_defining_expr().label("anc_up"), - Rm.predicate_kind, - Rm.predicate_subkind, - ) - .join(Rev, Rel.reverse_relationship_id == Rev.relationship_id) - .join(Rm, Rel.relationship_id == Rm.relationship_id) - ) + + return select( + Rel.relationship_id, + Rel.relationship_name, + Rel.reverse_relationship_id, + Rel.is_hierarchical_relationship_expr().label("is_hierarchical"), + Rel.is_ancestry_defining_expr().label("anc_down"), + Rev.is_ancestry_defining_expr().label("anc_up"), + ).join(Rev, Rel.reverse_relationship_id == Rev.relationship_id) def q_edges( concept_ids: Union[Tuple[int, ...], int], direction: Literal["in", "out"], predicate_ids: Optional[frozenset[str]] = None, - predicate_kinds: Optional[frozenset[PredicateKind]] = None, active_only: bool = False, on: Optional[date] = None, within_domain: bool = False, ) -> Select: - """Query outgoing edges for a batch of concept IDs.""" + """Query edges for a batch of concept IDs, in either direction. + + Parameters + ---------- + direction : {"in", "out"} + Whether to query incoming or outgoing edges for ``concept_ids``. + """ if isinstance(concept_ids, int): concept_ids = (concept_ids,) @@ -504,11 +465,6 @@ def q_edges( Concept_Relationship.valid_start_date, Concept_Relationship.valid_end_date, Concept_Relationship.invalid_reason, - RelationshipMapping.predicate_kind, - RelationshipMapping.predicate_subkind, - ).join( - RelationshipMapping, - Concept_Relationship.relationship_id == RelationshipMapping.relationship_id, ) if active_only: @@ -533,8 +489,6 @@ def q_edges( if predicate_ids: # Exact ID's stmt = stmt.where(Concept_Relationship.relationship_id.in_(predicate_ids)) - if predicate_kinds: # Global categories - stmt = stmt.where(RelationshipMapping.predicate_kind.in_(predicate_kinds)) return stmt diff --git a/src/omop_graph/oaklib_interface/omop_factory.py b/src/omop_graph/oaklib_interface/omop_factory.py deleted file mode 100644 index e4bef90..0000000 --- a/src/omop_graph/oaklib_interface/omop_factory.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Factory for creating OAK OMOP ontology resources.""" - -from __future__ import annotations - -from typing import Optional, Union - -from sqlalchemy.engine import URL - -from .omop_resource import OMOPOntologyResource -from oa_configurator import Resolver -from omop_graph.config import OmopGraphConfig - - -def omop_resource( - *, - url: Optional[Union[str, URL]] = None, - slug: Optional[str] = "omop", -) -> OMOPOntologyResource: - """Create an OMOP DatabaseOntologyResource. - - When url is omitted, reads connection details from the active oa-configurator - stack config. Pass url explicitly to override. - - Parameters - ---------- - url : str | URL, optional - Explicit database connection URL. When omitted the active oa-configurator - config is used. - slug : str, optional - Slug identifier for the resource. Defaults to 'omop'. - - Returns - ------- - OMOPOntologyResource - """ - if url is None: - resolver = Resolver.from_active_config() - db_name = resolver.resolve_package_config(OmopGraphConfig).cdm_db - database = resolver.resolve_database(db_name) - url = database.connection.url - - return OMOPOntologyResource( - slug=slug, - url=url, - ) diff --git a/src/omop_graph/oaklib_interface/omop_implementation.py b/src/omop_graph/oaklib_interface/omop_implementation.py index d11b232..f3fc47e 100644 --- a/src/omop_graph/oaklib_interface/omop_implementation.py +++ b/src/omop_graph/oaklib_interface/omop_implementation.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from typing import Dict, Iterable, Iterator, List, Optional, Tuple +from typing import ClassVar, Dict, Iterable, Iterator, List, Optional, Tuple import numpy as np from omop_alchemy.cdm.query import ConceptFilter @@ -36,12 +36,10 @@ from omop_graph.reasoning.resolvers.resolver_pipeline import ResolverPipeline from omop_graph.render import bind_default_renderers from omop_graph.oaklib_interface.omop_resource import OMOPOntologyResource -from omop_graph.oaklib_interface.omop_factory import omop_resource +from omop_graph.db.session import cdm_session_factory, resolve_cdm_database - -from sqlalchemy.engine import URL - -from omop_graph.db.session import make_engine +from oa_configurator import ResolvedCDMDatabase +from oaklib.resource import OntologyResource logger = logging.getLogger(__name__) @@ -538,15 +536,13 @@ def entities( # ty: ignore[invalid-method-override] Concept identifiers. """ - with self.kg.session_factory() as session: - cids = tuple( - self.kg.entities( - session=session, - domain=domain, - standard_only=standard_only, - filter_obsoletes=filter_obsoletes, - ) + cids = tuple( + self.kg.entities( + domain=domain, + standard_only=standard_only, + filter_obsoletes=filter_obsoletes, ) + ) for cid in cids: yield self._concept_curie(cid) @@ -644,16 +640,14 @@ def relationships( # ty: ignore[invalid-method-override] else None ) - with self.kg.session_factory() as session: - relationships = tuple( - self.kg.relationships( - session=session, - subjects=subject_ids, - predicates=predicate_ids, - objects=object_ids, - invert=invert, - ) + relationships = tuple( + self.kg.relationships( + subjects=subject_ids, + predicates=predicate_ids, + objects=object_ids, + invert=invert, ) + ) for s, p, o in relationships: yield ( @@ -735,6 +729,9 @@ def entailed_outgoing_relationships( ) -> Iterable[Tuple[PRED_CURIE, CURIE]]: """ Retrieve outgoing relationships, including those implied by the hierarchy. + + Currently disabled: always raises ``NotImplementedError``, per the + raise message below, since a CDM change broke the body's assumptions. """ raise NotImplementedError("Changes to the CDM currently prevents this function") concept_id = self._parse_concept(curie) @@ -743,23 +740,24 @@ def entailed_outgoing_relationships( {self._parse_predicate(p) for p in predicates} if predicates else None ) - for edge in self.kg.iter_edges( - concept_id, direction="out", predicate_kinds=None - ): - if pred_filter and edge.predicate_id not in pred_filter: - continue + with self.kg.session_factory() as session: + for edge in self.kg.iter_edges( + session=session, concept_ids=concept_id, direction="out", predicate_kinds=None + ): + if pred_filter and edge.predicate_id not in pred_filter: + continue - pred_curie = self._predicate_curie(edge.predicate_id) + pred_curie = self._predicate_curie(edge.predicate_id) - # hierarchical entailment - if self.kg.predicate_kind(edge.predicate_id) == PredicateKind.HIERARCHY: - yield pred_curie, self._concept_curie(edge.object_id) + # hierarchical entailment + if self.kg.predicate_kind(edge.predicate_id) == PredicateKind.HIERARCHY: + yield pred_curie, self._concept_curie(edge.object_id) - for parent in self.kg.parents(edge.object_id): - yield pred_curie, self._concept_curie(parent) + for parent in self.kg.parents(edge.object_id): + yield pred_curie, self._concept_curie(parent) - else: - yield pred_curie, self._concept_curie(edge.object_id) + else: + yield pred_curie, self._concept_curie(edge.object_id) def entailed_outputgoing_relationships_by_curie( self, *args, **kwargs @@ -810,6 +808,9 @@ def entailed_relationships_between( ) -> Iterable[PRED_CURIE]: """ Find relationships connecting a subject and object, including hierarchical ones. + + Currently disabled: always raises ``NotImplementedError``, per the + raise message below, since a CDM change broke the body's assumptions. """ raise NotImplementedError( "Change in OMOP CDM made this function not work anymore" @@ -819,9 +820,10 @@ def entailed_relationships_between( obj_id = self._parse_concept(object) # direct relationships - for edge in self.kg.iter_edges(subj_id, direction="out"): - if edge.object_id == obj_id: - yield self._predicate_curie(edge.predicate_id) + with self.kg.session_factory() as session: + for edge in self.kg.iter_edges(session=session, concept_ids=subj_id, direction="out"): + if edge.object_id == obj_id: + yield self._predicate_curie(edge.predicate_id) # hierarchical entailment if obj_id in self.kg.parents(subj_id): @@ -837,74 +839,59 @@ class OMOPAlchemyImplementation( # type: ignore[override] A :class:`OntologyInterface` implementation wrapping a SQL Relational Database conforming to the OMOP CDM. - To connect, either use OMOPAlchemyImplementation directly: + Select it through oaklib with an oa-configurator database name, or omit + the name to use ``OmopGraphConfig.cdm_db``: - >>> from omop_graph.oaklib_interface import OMOPAlchemyImplementation - >>> from omop_graph.oaklib_interface.omop_factory import omop_resource - >>> resource = omop_resource(url='postgresql+psycopg2://uid:pid@host:5432/dbname') - >>> adapter = OMOPAlchemyImplementation(resource=resource) + >>> from oaklib import get_adapter + >>> adapter = get_adapter("omop:cdm_db") - or pass a connection string directly: + or construct it directly from an already-resolved database: - >>> adapter = OMOPAlchemyImplementation(engine_string="sqlite:////path/to/omop.db") + >>> from omop_graph.db.session import resolve_cdm_database + >>> adapter = OMOPAlchemyImplementation(resolved=resolve_cdm_database()) Parameters ---------- - engine_string : str | URL | None, optional - The database connection string. Required unless ``resource`` is given. - resource : OMOPOntologyResource | None, optional - An existing resource object. Takes precedence over ``engine_string`` when - both are supplied. To use the oa-configurator-configured default, - resolve it explicitly via ``omop_resource()`` and pass it here. + resource : OntologyResource | None, optional + The oaklib resource. Its ``slug`` names the ``[databases.*]`` entry to + resolve; ``None`` resolves ``OmopGraphConfig.cdm_db``. Ignored when + ``resolved`` or ``kg`` is given. + resolved : ResolvedCDMDatabase | None, optional + An already-resolved CDM database to build the engines from with + ``create_cdm_engines()``, without registering schema claims. Ignored + when ``kg`` is given. kg : KnowledgeGraph | None, optional - An existing Knowledge Graph instance. If None, one is created from - ``engine_string`` / ``resource``. + An existing Knowledge Graph, used as-is. The caller owns whatever + engine it wraps. kg_emb_config : KnowledgeGraphEmbeddingConfiguration | None, optional Embedding configuration forwarded to the ``KnowledgeGraph`` constructor. - Required to enable embedding-based similarity. See - :class:`~omop_graph.graph.kg.KnowledgeGraphEmbeddingConfiguration`. - - Raises - ------ - ValueError - If neither ``engine_string`` nor ``resource`` is given. + Ignored when ``kg`` is given. """ + # oaklib's class_resolver keys plugins by class name; this adds the "omop:" scheme. + synonyms: ClassVar[tuple[str, ...]] = ("omop",) + def __init__( self, - engine_string: str | URL | None = None, - resource: OMOPOntologyResource | None = None, + resource: OntologyResource | None = None, + *, + resolved: ResolvedCDMDatabase | None = None, kg: KnowledgeGraph | None = None, kg_emb_config: Optional[KnowledgeGraphEmbeddingConfiguration] = None, **kwargs, ): - if engine_string is not None: - self.engine_string = engine_string - self.resource = resource or omop_resource(url=self.engine_string) - elif resource is not None: - self.resource = resource - self.engine_string = self.resource.url - else: - raise ValueError( - "OMOPAlchemyImplementation requires either 'engine_string' or " - "'resource'. To use the oa-configurator-configured default, " - "resolve it explicitly first, e.g. " - "OMOPAlchemyImplementation(resource=omop_resource())." - ) - - assert self.engine_string is not None, ( - "No database URL provided for OMOPAlchemyImplementation" - ) - - engine = make_engine(self.engine_string, engine_kwargs={"echo": False, "future": True}) - - self._connection = None - + if resource is None: + resource = OMOPOntologyResource() if kg is None: - kg = KnowledgeGraph(emb_config=kg_emb_config, cdm_engine=engine) + if resolved is None: + resolved = resolve_cdm_database(resource.slug) + kg = KnowledgeGraph( + cdm_session_factory(resolved=resolved), + emb_config=kg_emb_config, + ) bind_default_renderers(kg) - super().__init__(kg=kg, **kwargs) + super().__init__(resource=resource, kg=kg, **kwargs) # TODO: Implement if necessary! def _all_relationships(self): diff --git a/src/omop_graph/oaklib_interface/omop_resource.py b/src/omop_graph/oaklib_interface/omop_resource.py index c5c73b1..e1b1238 100644 --- a/src/omop_graph/oaklib_interface/omop_resource.py +++ b/src/omop_graph/oaklib_interface/omop_resource.py @@ -1,77 +1,40 @@ from dataclasses import dataclass -from typing import Optional, Union +from typing import Optional from oaklib.resource import OntologyResource -from sqlalchemy.engine import URL, make_url @dataclass class OMOPOntologyResource(OntologyResource): """ - Ontology resource backed by a live SQLAlchemy database. + Ontology resource naming an oa-configurator CDM database. - This class extends the `OntologyResource` to support database URLs specifically - for OMOP backends using SQLAlchemy. + Selected by oaklib as ``get_adapter("omop:")``. Parameters ---------- - url : str | URL, optional - The database connection URL. slug : str, optional - A unique slug/identifier for this resource. + Name of the ``[databases.*]`` entry to resolve. ``None`` resolves + ``OmopGraphConfig.cdm_db``. scheme : str, optional - The scheme identifier. Defaults to 'omop_alchemy'. - local : bool, optional - Whether the resource is local. Defaults to False. - in_memory : bool, optional - Whether the resource is in-memory. Defaults to False. + The oaklib scheme. Defaults to 'omop'. readonly : bool, optional Whether the resource is read-only. Defaults to True. """ - url: Optional[Union[str, URL]] = None # type: ignore[assignment] slug: Optional[str] = None # type: ignore[assignment] - scheme: str = "omop_alchemy" # type: ignore[assignment] - local: bool = False # type: ignore[assignment] - in_memory: bool = False # type: ignore[assignment] + scheme: str = "omop" # type: ignore[assignment] readonly: bool = True # type: ignore[assignment] - def _parsed_url(self) -> Optional[URL]: - """ - Parse the connection URL into a SQLAlchemy URL object. - - Returns - ------- - URL | None - The parsed URL object, or None if no URL is set. - """ - if not self.url: - return None - return make_url(self.url) if isinstance(self.url, str) else self.url - - @property - def display_slug(self) -> Optional[str]: - """ - Get a safe, redacted identifier for logs / UI. - - Returns - ------- - str | None - The string representation of the parsed URL. - """ - u = self._parsed_url() - return str(u) if u else None - def valid(self) -> bool: """ - Check if the database ontology resource is valid. + Always True: a missing slug resolves the configured default database. Returns ------- bool - True if a URL is present, False otherwise. """ - return bool(self.url) + return True @property def local_path(self) -> None: @@ -81,15 +44,6 @@ def local_path(self) -> None: Returns ------- None - Always returns None as database-backed resources have no filesystem path. + Always None as database-backed resources have no filesystem path. """ return None - - def __repr__(self) -> str: - parts = [] - if self.slug: - parts.append(f"slug={self.slug!r}") - if self.url: - parts.append(f"url={str(make_url(self.url))!r}") - parts.append(f"scheme={self.scheme!r}") - return f"{type(self).__name__}({', '.join(parts)})" diff --git a/src/omop_graph/reasoning/concept_handlers/concept_helpers.py b/src/omop_graph/reasoning/concept_handlers/concept_helpers.py index 25c0822..6a5381d 100644 --- a/src/omop_graph/reasoning/concept_handlers/concept_helpers.py +++ b/src/omop_graph/reasoning/concept_handlers/concept_helpers.py @@ -18,9 +18,15 @@ def standardise_ids( """ Map a set of concept IDs to their Standard Concept IDs. - This function attempts to find a 'Maps to' relationship for each input ID. - If a 'Maps to' edge exists, the target ID is used (Standard Concept). - If no such edge exists, the original ID is returned (fallback to self). + Currently disabled: always raises ``NotImplementedError``. The predicate + search this relied on changed and the body below was never updated to + match (it also still calls ``kg.iter_edges(predicate=...)``, a parameter + that no longer exists; see ``predicate_ids``/``predicate_kinds``). + + Intended behavior once reimplemented: attempts to find a 'Maps to' + relationship for each input ID. If a 'Maps to' edge exists, the target ID + is used (Standard Concept). If no such edge exists, the original ID is + returned (fallback to self). Parameters ---------- diff --git a/src/omop_graph/reasoning/phenotypes/phenotype_simplifier.py b/src/omop_graph/reasoning/phenotypes/phenotype_simplifier.py index 2dbd4a8..b5830ef 100644 --- a/src/omop_graph/reasoning/phenotypes/phenotype_simplifier.py +++ b/src/omop_graph/reasoning/phenotypes/phenotype_simplifier.py @@ -22,7 +22,12 @@ def descendants_exhaustive_subsumes( exclude_roots: set[int] | None = None, ) -> set[int]: """ - Exhaustive descendant closure using ONLY 'Subsumes' + Exhaustive descendant closure using ONLY 'Subsumes'. + + Currently disabled: always raises ``NotImplementedError``. The predicate + search this relied on changed and the body below was never updated to + match (it also still calls ``kg.iter_edges(predicate=...)``, a parameter + that no longer exists; see ``predicate_ids``/``predicate_kinds``). """ if exclude_roots is None: diff --git a/src/omop_graph/reasoning/resolvers/resolvers.py b/src/omop_graph/reasoning/resolvers/resolvers.py index a1c60e4..564677a 100644 --- a/src/omop_graph/reasoning/resolvers/resolvers.py +++ b/src/omop_graph/reasoning/resolvers/resolvers.py @@ -45,6 +45,9 @@ class CandidateHit: The kind of match of this hit. matched_concept_label : str The specific text in the database (name or synonym) that matched. + synonym : bool + Whether ``matched_concept_label`` came from a synonym rather than + the concept's own name. """ concept_id: int diff --git a/tests/conftest.py b/tests/conftest.py index 4d5115d..7e42648 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,28 @@ pytest_plugins = ("fixtures.mock_cdm",) +@pytest.fixture +def pg_db(request): + """Canonical isolated PostgreSQL test database (Phase 0 of the + schema_translate_map fix). + + Resolves via OA_Configurator resource 'test_cdm_db_pg' in ~/.config/omop/config.toml. + Run: omop-config configure omop_graph (answer Y when asked to configure test database). + + Everything a test does through ``pg_db.connection``/``pg_db.session`` + happens inside one transaction that's rolled back on exit. Nothing + here is ever committed to the shared server, so concurrent test runs + can't collide and no manual cleanup is needed. omop-graph has no + existing Postgres test fixture; built from scratch here, matching the + convention already used by OMOP_Alchemy/orm-loader/omop-emb. + """ + from oa_configurator.testing import isolated_test_database + from omop_graph.config import OmopGraphConfig + + with isolated_test_database(OmopGraphConfig, "test_cdm_db_pg", request=request) as db: + yield db + + class WhitelistFilter(logging.Filter): def __init__(self, whitelist): self.whitelist = whitelist @@ -17,20 +39,10 @@ def filter(self, record): @pytest.fixture(autouse=True, scope="session") def configure_logging_whitelist(): + """Attach the filter to the root logger's handlers, not the logger itself, + so the check runs right before text actually reaches the screen. """ - Attaches the filter to the HANDLERS, not the logger. - """ - # Your allowed list - my_whitelisted_loggers = ["omop_graph", "orm_loader", "omop_spires", "tests"] - - # Instantiate the filter - my_filter = WhitelistFilter(my_whitelisted_loggers) - - # Get the root logger - root_logger = logging.getLogger() - - # --- THE FIX --- - # We iterate over the handlers (Console, File, etc.) and attach the filter there. - # This forces the check to happen right before the text hits the screen. - for handler in root_logger.handlers: + whitelisted_loggers = ["omop_graph", "orm_loader", "omop_spires", "tests"] + my_filter = WhitelistFilter(whitelisted_loggers) + for handler in logging.getLogger().handlers: handler.addFilter(my_filter) diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py new file mode 100644 index 0000000..7de6513 --- /dev/null +++ b/tests/fixtures/helpers.py @@ -0,0 +1,23 @@ +"""Shared test-helper primitives for vocab-schema fixtures.""" + +from __future__ import annotations + +from typing import cast + +import sqlalchemy as sa +from omop_alchemy.cdm.model.vocabulary import ( + Concept, + Concept_Class, + Domain, + Vocabulary +) + +VOCAB_TABLES = cast( + "tuple[sa.Table, ...]", + (Domain.__table__, Vocabulary.__table__, Concept_Class.__table__, Concept.__table__), +) +"""Domain/Vocabulary/Concept_Class/Concept: the minimal vocab bootstrap set +every split/non-split fixture needs, in FK dependency order. A consumer +needing more tables (e.g. Relationship, Concept_Relationship) extends this +tuple rather than redefining its own copy of the shared core. +""" diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 2527d5e..60c5648 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -1,13 +1,14 @@ from __future__ import annotations from datetime import date -from typing import cast +from typing import Iterator, cast import pytest import sqlalchemy as sa from sqlalchemy.orm import Session, sessionmaker -from orm_loader.helpers import Base +from oa_configurator.testing import isolated_test_database, scoped_test_schema +from orm_loader.helpers import Base, bulk_load_context from omop_alchemy.cdm.model.vocabulary.concept import Concept from omop_alchemy.cdm.model.vocabulary.concept_ancestor import Concept_Ancestor from omop_alchemy.cdm.model.vocabulary.concept_class import Concept_Class @@ -17,6 +18,7 @@ from omop_alchemy.cdm.model.vocabulary.relationship import Relationship from omop_alchemy.cdm.model.vocabulary.vocabulary import Vocabulary +from omop_graph.config import OmopGraphConfig from omop_graph.extensions.omop_alchemy import ( PredicateKind, RelationshipClass, @@ -30,8 +32,26 @@ @pytest.fixture(scope="module") -def mock_cdm_engine() -> sa.Engine: - engine = sa.create_engine("sqlite+pysqlite:///:memory:", future=True) +def mock_cdm_engine() -> Iterator[sa.Engine]: + with isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as db: + with scoped_test_schema(db.resolved, prefix="mock_cdm") as scoped: + _create_mock_cdm_tables(scoped.engine) + yield scoped.engine + + +@pytest.fixture(scope="module") +def mock_cdm_engine_sqlite() -> Iterator[sa.Engine]: + engine = sa.create_engine("sqlite+pysqlite:///:memory:").execution_options( + schema_translate_map={"primary": None, "vocab": None} + ) + _create_mock_cdm_tables(engine) + try: + yield engine + finally: + engine.dispose() + + +def _create_mock_cdm_tables(engine: sa.Engine) -> None: tables = cast( list[sa.Table], [ @@ -50,25 +70,41 @@ def mock_cdm_engine() -> sa.Engine: Base.metadata.create_all(engine, tables=tables) + # Disabling FK checks for the whole seed sidesteps ordering entirely, + # since these tables have FK dependencies that form a cycle session_local = sessionmaker(bind=engine, future=True) with session_local() as session: - seed_mock_cdm(session) - - return engine + if engine.dialect.name == "postgresql": + with bulk_load_context(session): + seed_mock_cdm(session) + else: + seed_mock_cdm(session) + session.commit() -@pytest.fixture() +@pytest.fixture( + params=[ + pytest.param("sqlite", id="sqlite"), + pytest.param( + "postgresql", + id="postgresql", + marks=[pytest.mark.postgresql, pytest.mark.db_dialect], + ), + ] +) def mock_cdm_kg( - mock_cdm_engine: sa.Engine, - monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch ) -> KnowledgeGraph: # Grounding tests here focus on SQL + resolver + path pipeline. monkeypatch.setattr( "omop_graph.reasoning.grounding.try_get_embedding_writer_interface", lambda _kg: None, ) - - return KnowledgeGraph(cdm_engine=mock_cdm_engine) + fixture_name = ( + "mock_cdm_engine_sqlite" if request.param == "sqlite" else "mock_cdm_engine" + ) + engine = request.getfixturevalue(fixture_name) + return KnowledgeGraph(sessionmaker(bind=engine)) def seed_mock_cdm(session: Session) -> None: @@ -353,4 +389,62 @@ def seed_mock_cdm(session: Session) -> None: ) ) - session.commit() + session.flush() + + +def seed_relationship_vocabulary(engine: sa.Engine) -> None: + """Seed the two package-mapped relationship IDs used by PG graph fixtures.""" + session_local = sessionmaker(bind=engine, future=True) + with session_local() as session: + with bulk_load_context(session): + if session.get(Concept, CONCEPT_META_ID) is None: + session.add_all( + [ + Concept( + concept_id=CONCEPT_META_ID, + concept_name="Meta concept", + domain_id="Metadata", + vocabulary_id="OMOP", + concept_class_id="Metadata", + standard_concept="S", + concept_code="META", + valid_start_date=date(2020, 1, 1), + valid_end_date=date(2099, 12, 31), + ), + Domain(domain_id="Metadata", domain_name="Metadata", domain_concept_id=CONCEPT_META_ID), + Vocabulary( + vocabulary_id="OMOP", + vocabulary_name="OMOP", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=CONCEPT_META_ID, + ), + Concept_Class( + concept_class_id="Metadata", + concept_class_name="Metadata", + concept_class_concept_id=CONCEPT_META_ID, + ), + ] + ) + session.add_all( + [ + Relationship( + relationship_id="Maps to", + relationship_name="Maps to", + is_hierarchical="0", + defines_ancestry="0", + reverse_relationship_id="Mapped from", + relationship_concept_id=CONCEPT_META_ID, + ), + Relationship( + relationship_id="Mapped from", + relationship_name="Mapped from", + is_hierarchical="0", + defines_ancestry="0", + reverse_relationship_id="Maps to", + relationship_concept_id=CONCEPT_META_ID, + ), + ] + ) + session.flush() + session.commit() diff --git a/tests/render/test_render_mmd.py b/tests/render/test_render_mmd.py deleted file mode 100644 index c0e9a90..0000000 --- a/tests/render/test_render_mmd.py +++ /dev/null @@ -1,7 +0,0 @@ -# def test_path_mermaid_snapshot(kg, example_path): -# from omop_graph.render import render_path -# -# out = render_path(kg, example_path, format="mmd") -# -# assert out.startswith("graph LR") -# assert "-->|Is a|" in out diff --git a/tests/render/test_render_text.py b/tests/render/test_render_text.py deleted file mode 100644 index 06446e7..0000000 --- a/tests/render/test_render_text.py +++ /dev/null @@ -1,9 +0,0 @@ -# def test_path_text_snapshot(kg, example_path): -# from omop_graph.render import render_path -# -# out = render_path(kg, example_path, format="text") -# -# assert out == """\ -# Aspirin --[Is a]--> Antiplatelet agent -# Antiplatelet agent --[Is a]--> Drug -# """ diff --git a/tests/test_concept_queries.py b/tests/test_concept_queries.py index 57e28b8..cada934 100644 --- a/tests/test_concept_queries.py +++ b/tests/test_concept_queries.py @@ -3,12 +3,16 @@ from __future__ import annotations from datetime import date +from typing import Iterator import pytest import sqlalchemy as sa from sqlalchemy.orm import Session -from omop_alchemy.cdm.model.vocabulary import Concept +from oa_configurator.testing import scoped_test_schema +from orm_loader.helpers import Base, bulk_load_context + +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary from omop_alchemy.cdm.query import ConceptFilter from omop_graph.graph.nodes import ConceptView @@ -19,47 +23,91 @@ q_entities, ) +from fixtures.helpers import VOCAB_TABLES + +_META_CONCEPT_ID = 0 + @pytest.fixture() -def concept_engine() -> sa.Engine: - engine = sa.create_engine("sqlite+pysqlite:///:memory:", future=True) - Concept.__table__.create(engine) - - valid_from = date(2000, 1, 1) - valid_until = date(2099, 12, 31) - - def concept( - concept_id: int, - *, - standard_concept: str | None, - invalid_reason: str | None, - ) -> Concept: - return Concept( - concept_id=concept_id, - concept_name="Shared label", - domain_id="Condition", - vocabulary_id="SNOMED", - concept_class_id="Clinical Finding", - standard_concept=standard_concept, - concept_code=f"TEST-{concept_id}", - valid_start_date=valid_from, - valid_end_date=valid_until, - invalid_reason=invalid_reason, - ) +def concept_engine(pg_db) -> Iterator[sa.Engine]: + with scoped_test_schema(pg_db.resolved, prefix="concept_queries") as scoped: + engine = scoped.engine + Base.metadata.create_all(engine, tables=VOCAB_TABLES, checkfirst=True) + + valid_from = date(2000, 1, 1) + valid_until = date(2099, 12, 31) + + def concept( + concept_id: int, + *, + standard_concept: str | None, + invalid_reason: str | None, + ) -> Concept: + return Concept( + concept_id=concept_id, + concept_name="Shared label", + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept=standard_concept, + concept_code=f"TEST-{concept_id}", + valid_start_date=valid_from, + valid_end_date=valid_until, + invalid_reason=invalid_reason, + ) - with Session(engine) as session: - session.add_all( - [ - concept(1, standard_concept="S", invalid_reason=None), - concept(2, standard_concept="C", invalid_reason=" "), - concept(3, standard_concept=None, invalid_reason=None), - concept(4, standard_concept="S", invalid_reason="U"), - concept(5, standard_concept=" ", invalid_reason="X"), - ] - ) - session.commit() + with Session(engine) as session: + with bulk_load_context(session): + session.add_all( + [ + Concept( + concept_id=_META_CONCEPT_ID, + concept_name="Meta concept", + domain_id="Metadata", + vocabulary_id="OMOP", + concept_class_id="Metadata", + standard_concept="S", + concept_code="META", + valid_start_date=valid_from, + valid_end_date=valid_until, + ), + Domain(domain_id="Metadata", domain_name="Metadata", domain_concept_id=_META_CONCEPT_ID), + Domain(domain_id="Condition", domain_name="Condition", domain_concept_id=_META_CONCEPT_ID), + Vocabulary( + vocabulary_id="OMOP", + vocabulary_name="OMOP", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=_META_CONCEPT_ID, + ), + Vocabulary( + vocabulary_id="SNOMED", + vocabulary_name="SNOMED", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=_META_CONCEPT_ID, + ), + Concept_Class( + concept_class_id="Metadata", + concept_class_name="Metadata", + concept_class_concept_id=_META_CONCEPT_ID, + ), + Concept_Class( + concept_class_id="Clinical Finding", + concept_class_name="Clinical Finding", + concept_class_concept_id=_META_CONCEPT_ID, + ), + concept(1, standard_concept="S", invalid_reason=None), + concept(2, standard_concept="C", invalid_reason=" "), + concept(3, standard_concept=None, invalid_reason=None), + concept(4, standard_concept="S", invalid_reason="U"), + concept(5, standard_concept=" ", invalid_reason="X"), + ] + ) + session.flush() + session.commit() - return engine + yield engine def test_concept_filter_applies_canonical_graph_constraints( diff --git a/tests/test_edges_same_connection_regression.py b/tests/test_edges_same_connection_regression.py new file mode 100644 index 0000000..5fea044 --- /dev/null +++ b/tests/test_edges_same_connection_regression.py @@ -0,0 +1,46 @@ +"""Edge classification on a colocated database. + +``KnowledgeGraph`` classifies every edge from the relationship mapping read at +construction, on any topology. These pin the colocated case, the one most +deployments use, including the ``predicate_kinds`` filter applied in SQL as +relationship IDs. +""" + +from __future__ import annotations + +import pytest + +from omop_graph.extensions.omop_alchemy import PredicateKind +from omop_graph.graph.kg import KnowledgeGraph + + +def test_edges_are_classified_from_the_relationship_mapping( + mock_cdm_kg: KnowledgeGraph, +) -> None: + edges = mock_cdm_kg.edges( + concept_ids=900001, + direction="out", + active_only=False, + within_domain=False, + ) + + assert len(edges) == 1 + edge = edges[0] + assert edge.subject_id == 900001 + assert edge.object_id == 196653 + assert edge.predicate_id == "maps to" + assert edge.predicate_kind == PredicateKind.IDENTITY + assert edge.predicate_subkind == "mapping" + + +@pytest.mark.parametrize(("kinds", "expected"), [({PredicateKind.IDENTITY}, 1), ({PredicateKind.HIERARCHY}, 0)]) +def test_predicate_kinds_filter_edges(mock_cdm_kg: KnowledgeGraph, kinds, expected) -> None: + edges = mock_cdm_kg.edges( + concept_ids=900001, + direction="out", + predicate_kinds=frozenset(kinds), + active_only=False, + within_domain=False, + ) + + assert len(edges) == expected diff --git a/tests/test_fulltext_optional.py b/tests/test_fulltext_optional.py index 6a385f4..cbb4c43 100644 --- a/tests/test_fulltext_optional.py +++ b/tests/test_fulltext_optional.py @@ -1,21 +1,44 @@ import pytest +import sqlalchemy as sa from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker from omop_alchemy.backends import FullTextError +from omop_graph.graph.kg import KnowledgeGraph +from omop_graph.graph.nodes import LabelMatchKind from omop_graph.graph.queries import q_concept_name_fulltext @pytest.mark.parametrize("synonym", [False, True]) +@pytest.mark.postgresql +@pytest.mark.db_dialect def test_fulltext_query_requires_tsvector_columns( synonym: bool, mock_cdm_engine: Engine ): """Full-text query raises FullTextError when tsvector columns are absent from the database. - The mock CDM engine is SQLite and never has tsvector columns, so the guard in + The mock CDM's own table creation never adds tsvector columns (that's a + separate, opt-in fulltext-install step), so the guard in q_concept_name_fulltext (which inspects the live DB schema) always fires here. """ with pytest.raises(FullTextError): q_concept_name_fulltext( "kidney cancer", synonym=synonym, engine=mock_cdm_engine ) + + +def test_knowledge_graph_fulltext_lookup_skips_unsupported_sqlite( + mock_cdm_engine_sqlite: Engine, +): + kg = KnowledgeGraph(sessionmaker(bind=mock_cdm_engine_sqlite)) + assert kg.concept_lookup("kidney cancer", LabelMatchKind.FTS) == () + + +def test_knowledge_graph_rejects_engine_instead_of_session_factory(): + engine = sa.create_engine("sqlite+pysqlite:///:memory:") + try: + with pytest.raises(TypeError, match="callable session factory.*not an Engine"): + KnowledgeGraph(engine) + finally: + engine.dispose() diff --git a/tests/test_fulltext_vocab_schema_postgres.py b/tests/test_fulltext_vocab_schema_postgres.py new file mode 100644 index 0000000..b9f0065 --- /dev/null +++ b/tests/test_fulltext_vocab_schema_postgres.py @@ -0,0 +1,57 @@ +"""q_concept_name_fulltext() must inspect the VOCAB schema, not primary. + +Concept/Concept_Synonym are VOCAB-tagged; inspecting without an explicit +schema= defaults to Role.PRIMARY, so a real primary/vocab split (unlike +SQLite, where every schema tag folds to None) would raise a false +FullTextError even though the tsvector column exists. +""" + +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from oa_configurator import Role +from oa_configurator.testing import scoped_test_schema +from omop_alchemy.backends import CONCEPT_NAME_TSVECTOR_COLUMN, FullTextError, resolve_backend +from orm_loader.helpers import Base + +from omop_graph.graph.queries import q_concept_name_fulltext + +from fixtures.helpers import VOCAB_TABLES + +pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] + + +def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): + with scoped_test_schema(pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB]) as scoped: + engine = scoped.engine + Base.metadata.create_all(bind=engine, tables=VOCAB_TABLES, checkfirst=True) + + with engine.begin() as conn: + resolve_backend(conn).install_fulltext_on_table( + conn, + table_name="concept", + vector_column_name=CONCEPT_NAME_TSVECTOR_COLUMN, + index_name="idx_concept_name_tsvector_test", + create_indexes=True, + fastupdate=True, + schema_tag=Role.VOCAB.value, + ) + + # Confirmed absent from the primary schema: proves a real vocab/primary + # split, not a lucky same-schema coincidence. + inspector = sa.inspect(engine) + assert not inspector.has_table("concept", schema=scoped.schemas[Role.PRIMARY]) + columns = {c["name"] for c in inspector.get_columns("concept", schema=scoped.schemas[Role.VOCAB])} + assert CONCEPT_NAME_TSVECTOR_COLUMN in columns + + stmt = q_concept_name_fulltext("kidney cancer", engine=engine) + assert stmt is not None + + +def test_fulltext_query_still_raises_when_the_column_is_genuinely_absent(pg_db): + with scoped_test_schema(pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB]) as scoped: + Base.metadata.create_all(bind=scoped.engine, tables=VOCAB_TABLES, checkfirst=True) + + with pytest.raises(FullTextError): + q_concept_name_fulltext("kidney cancer", engine=scoped.engine) diff --git a/tests/test_grounding.py b/tests/test_grounding.py index 606b7c3..f91216d 100644 --- a/tests/test_grounding.py +++ b/tests/test_grounding.py @@ -17,7 +17,7 @@ PartialLabelResolver, PartialSynonymResolver, ) -from fixtures.mock_cdm import PARENT_CANCER_ID # type: ignore +from fixtures.mock_cdm import PARENT_CANCER_ID class TestQueryTextWithContext: diff --git a/tests/test_kg_ancestry.py b/tests/test_kg_ancestry.py index 95d1b49..dfe4dba 100644 --- a/tests/test_kg_ancestry.py +++ b/tests/test_kg_ancestry.py @@ -4,8 +4,7 @@ from omop_graph.graph.nodes import LabelMatchKind from omop_graph.graph.paths import find_standard_paths from omop_graph.reasoning.resolvers.resolvers import CandidateHit -from fixtures.mock_cdm import PARENT_CANCER_ID, CONCEPT_META_ID # type: ignore - +from fixtures.mock_cdm import PARENT_CANCER_ID, CONCEPT_META_ID def test_get_potential_ancestors_batch_returns_only_real_ancestors( mock_cdm_kg: KnowledgeGraph, diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py new file mode 100644 index 0000000..1afe5ce --- /dev/null +++ b/tests/test_oaklib_schema_awareness.py @@ -0,0 +1,171 @@ +"""OAK-lib adapter construction. + +``OMOPAlchemyImplementation`` builds its engines only through +``ResolvedCDMDatabase.create_engines()``. Three construction paths: + +1. ``get_adapter("omop:")``: oaklib passes a resource whose slug names + the oa-configurator database entry. +2. ``resolved=``: an already-resolved CDM database. +3. ``kg=``: an existing ``KnowledgeGraph``, used as-is. +""" + +from __future__ import annotations + +from datetime import date + +import pytest +import sqlalchemy as sa +import sqlalchemy.orm +from oaklib import get_adapter + +from oa_configurator import Resolver, Role +from oa_configurator.testing import scoped_test_schema +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary +from omop_alchemy.cross_database import cdm_sessionmaker +from orm_loader.backends import staging_schema_claim +from orm_loader.helpers import Base, bulk_load_context + +from omop_graph.cli import relationship_classification +from omop_graph.db.session import resolve_cdm_database +from omop_graph.extensions.omop_alchemy import RelationshipMapping +from omop_graph.graph.kg import KnowledgeGraph +from omop_graph.oaklib_interface import omop_implementation +from omop_graph.oaklib_interface.omop_implementation import OMOPAlchemyImplementation +from fixtures.mock_cdm import seed_relationship_vocabulary + +_META_CONCEPT_ID = 0 +_CONCEPT_ID = 1001 +_TODAY = date(2020, 1, 1) +_FAR_FUTURE = date(2099, 12, 31) + + +def _seed_one_concept(engine: sa.Engine, *, concept_id: int, name: str) -> None: + """Minimal vocab bootstrap: Domain/Vocabulary/Concept_Class/Concept form an + FK insert cycle, so FK checks are disabled around the insert. + """ + with sa.orm.Session(engine) as session: + with bulk_load_context(session): + session.add_all( + [ + Concept( + concept_id=_META_CONCEPT_ID, + concept_name="Meta concept", + domain_id="Metadata", + vocabulary_id="OMOP", + concept_class_id="Metadata", + standard_concept="S", + concept_code="META", + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + ), + Concept( + concept_id=concept_id, + concept_name=name, + domain_id="Metadata", + vocabulary_id="OMOP", + concept_class_id="Metadata", + standard_concept="S", + concept_code=str(concept_id), + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + ), + Domain(domain_id="Metadata", domain_name="Metadata", domain_concept_id=_META_CONCEPT_ID), + Vocabulary( + vocabulary_id="OMOP", + vocabulary_name="OMOP", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=_META_CONCEPT_ID, + ), + Concept_Class( + concept_class_id="Metadata", + concept_class_name="Metadata", + concept_class_concept_id=_META_CONCEPT_ID, + ), + ] + ) + session.flush() + session.commit() + + +def _populate(scoped, *, name: str) -> None: + Base.metadata.create_all(bind=scoped.engine, checkfirst=True) + _seed_one_concept(scoped.engine, concept_id=_CONCEPT_ID, name=name) + seed_relationship_vocabulary(scoped.engine) + relationship_classification(resolved=scoped.resolved) + + +@pytest.mark.parametrize(("descriptor", "expected_name"), [("omop:named_db", "named_db"), ("omop:", None)]) +def test_get_adapter_resolves_the_slug_as_the_database_name( + pg_db, monkeypatch, descriptor, expected_name +) -> None: + with scoped_test_schema(pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim()]) as scoped: + _populate(scoped, name="Adapter concept") + + def fake_resolve_cdm_database(name): + assert name == expected_name + return scoped.resolved + + monkeypatch.setattr(omop_implementation, "resolve_cdm_database", fake_resolve_cdm_database) + adapter = get_adapter(descriptor) + + assert isinstance(adapter, OMOPAlchemyImplementation) + assert adapter.resource.scheme == "omop" + assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Adapter concept" + + +def _one_engine_for_both_roles(kg: KnowledgeGraph) -> bool: + """Do the graph's sessions send vocabulary and extension tables to one engine?""" + with kg.session_factory() as session: + return session.get_bind(Concept) is session.get_bind(RelationshipMapping) + + +def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: + assert resolve_cdm_database(pg_db.resolved.name).name == pg_db.resolved.name + + +def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: + with scoped_test_schema(pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim()]) as scoped: + _populate(scoped, name="Resolved-path concept") + + adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) + + assert _one_engine_for_both_roles(adapter.kg) + with adapter.kg.session_factory() as session: + assert session.get_bind(Concept).pool._pre_ping is True + assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Resolved-path concept" + + +def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) -> None: + """A second connection entry naming the same physical database is not a + split, so the adapter gets one engine for both roles and the cross-schema + foreign key stays creatable. Deciding this by config identity instead + would open a second pool to a database already in use and drop that key.""" + name = pg_db.resolved.name + resolver = Resolver.from_active_config() + resolver = resolver.with_overrides( + connections={"oaklib_split_vocab": resolver.config.connections[pg_db.resolved.connection.name]}, + databases={name: resolver.config.databases[name].model_copy(update={"vocab_connection": "oaklib_split_vocab"})}, + ) + with scoped_test_schema(resolver.resolve_database(name), prefix="oaklib_split", resolver=resolver, schema_claims=[staging_schema_claim()]) as scoped: + assert scoped.resolved.connection.name != scoped.resolved.vocab_connection.name + assert scoped.resolved.foreign_key_can_span(Role.PRIMARY, Role.VOCAB) + _populate(scoped, name="Split-wiring concept") + + adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) + + assert _one_engine_for_both_roles(adapter.kg) + assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Split-wiring concept" + + +def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: + with scoped_test_schema(pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim()]) as scoped: + _populate(scoped, name="Injected concept") + kg = KnowledgeGraph( + cdm_sessionmaker(scoped.resolved, primary=scoped.engine, vocab=scoped.engine) + ) + + adapter = OMOPAlchemyImplementation(kg=kg) + + assert adapter.kg is kg + assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Injected concept" diff --git a/tests/test_pg_db_fixture.py b/tests/test_pg_db_fixture.py new file mode 100644 index 0000000..fb9fbc2 --- /dev/null +++ b/tests/test_pg_db_fixture.py @@ -0,0 +1,29 @@ +"""Smoke test for the pg_db fixture (Phase 0 of the schema_translate_map fix). + +omop-graph had no Postgres test fixture at all before this -- this proves +the newly-added one actually works, not just that it's wired up. +""" + +import sqlalchemy as sa + + +def test_pg_db_yields_a_working_connection_and_session(pg_db): + assert pg_db.connection.execute(sa.text("SELECT 1")).scalar() == 1 + assert pg_db.session.connection() is pg_db.connection + + +def test_pg_db_creates_table_for_rollback_check(pg_db): + """Create a table whose absence is checked by the next test.""" + exists = pg_db.connection.execute( + sa.text("SELECT to_regclass('pg_db_fixture_smoke_test')") + ).scalar() + assert exists is None + pg_db.connection.execute(sa.text("CREATE TABLE pg_db_fixture_smoke_test (id INT)")) + + +def test_pg_db_rolls_back_between_tests(pg_db): + """The table created by the preceding test must have been rolled back.""" + exists = pg_db.connection.execute( + sa.text("SELECT to_regclass('pg_db_fixture_smoke_test')") + ).scalar() + assert exists is None diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py new file mode 100644 index 0000000..af1bb27 --- /dev/null +++ b/tests/test_relationship_classification.py @@ -0,0 +1,103 @@ +"""Regression test for the originally reported bug: relationship-classification +silently ignored the configured CDM schema. + +Each test runs against a committed scoped schema from ``scoped_test_schema()``, +dropped on exit. Also covers the DROP TYPE naming-mismatch fix: the enum +column never set an explicit ``name=``, so the real generated type is +``predicatekind``, not ``predicatekindenum``. +""" + +from __future__ import annotations + +import logging + +import sqlalchemy as sa +from oa_configurator import Role +from oa_configurator.testing import scoped_test_schema + +from orm_loader.backends import staging_schema_claim +from orm_loader.helpers import Base + +from omop_graph.cli import relationship_classification +from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping +from fixtures.mock_cdm import seed_relationship_vocabulary + + +def test_relationship_classification_respects_the_configured_schema(pg_db): + with scoped_test_schema(pg_db.resolved, prefix="relclass_routing", schema_claims=[staging_schema_claim()]) as scoped: + schema = scoped.schemas[Role.PRIMARY] + Base.metadata.create_all(bind=scoped.engine, checkfirst=True) + seed_relationship_vocabulary(scoped.engine) + + relationship_classification(resolved=scoped.resolved) + + with scoped.engine.connect() as conn: + n_class = conn.execute( + sa.select(sa.func.count()).select_from(RelationshipClass.__table__) + ).scalar() + n_mapping = conn.execute( + sa.select(sa.func.count()).select_from(RelationshipMapping.__table__) + ).scalar() + enum_schema = conn.execute( + sa.text( + "SELECT n.nspname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE t.typname = 'predicatekind' AND n.nspname = :schema" + ), + {"schema": schema}, + ).scalar() + assert n_class and n_class > 0 + assert n_mapping and n_mapping > 0 + assert sa.inspect(scoped.engine).has_table("relationship_class", schema=schema) + assert enum_schema == schema + + +def test_relationship_classification_is_idempotent(pg_db): + """Re-running against the same schema, the real-world redeploy case the + DROP TABLE/enum-drop cleanup exists for, must not fail.""" + with scoped_test_schema(pg_db.resolved, prefix="relclass_idempotent", schema_claims=[staging_schema_claim()]) as scoped: + Base.metadata.create_all(bind=scoped.engine, checkfirst=True) + seed_relationship_vocabulary(scoped.engine) + + relationship_classification(resolved=scoped.resolved) + relationship_classification(resolved=scoped.resolved) + + with scoped.engine.connect() as conn: + n_class = conn.execute( + sa.select(sa.func.count()).select_from(RelationshipClass.__table__) + ).scalar() + assert n_class and n_class > 0 + + +def test_relationship_classification_warns_and_drops_unknown_ids_colocated( + pg_db, tmp_path, caplog +): + (tmp_path / "predicate_classification.csv").write_text( + "class,subclass,description,semantics,inference\n" + "Identity,mapping,Identity mapping,identity,none\n" + ) + (tmp_path / "predicate_mapping.csv").write_text( + "class,subclass,r_id,r_name,classification_notes,additional_info,link\n" + "Identity,mapping,Maps to,Maps to,,,\n" + "Identity,mapping,unknown_rel,Unknown,,,\n" + ) + + with scoped_test_schema( + pg_db.resolved, + prefix="relclass_unknown", + schema_claims=[staging_schema_claim()], + ) as scoped: + Base.metadata.create_all(bind=scoped.engine, checkfirst=True) + seed_relationship_vocabulary(scoped.engine) + + with caplog.at_level(logging.WARNING, logger="omop_graph.cli"): + relationship_classification(str(tmp_path), resolved=scoped.resolved) + + with scoped.engine.connect() as connection: + relationship_ids = set( + connection.execute( + sa.select(RelationshipMapping.relationship_id) + ).scalars() + ) + + assert relationship_ids == {"Maps to"} + assert "unknown_rel" in caplog.text diff --git a/tests/test_relationship_classification_mapping.py b/tests/test_relationship_classification_mapping.py new file mode 100644 index 0000000..076e4de --- /dev/null +++ b/tests/test_relationship_classification_mapping.py @@ -0,0 +1,36 @@ +import logging + +import pandas as pd +import pytest + +from omop_graph.cli import _filter_unknown_relationship_ids + + +def test_unknown_relationship_ids_are_warned_about_and_dropped(caplog): + mapping = pd.DataFrame( + { + "relationship_id": ["maps to", "unknown relationship"], + "predicate_kind": ["Association", "Association"], + "predicate_subkind": ["mapping", "mapping"], + } + ) + + with caplog.at_level(logging.WARNING, logger="omop_graph.cli"): + filtered = _filter_unknown_relationship_ids(mapping, {"maps to"}) + + assert filtered["relationship_id"].tolist() == ["maps to"] + assert "unknown relationship" in caplog.text + + +def test_empty_relationship_vocabulary_fails_clearly(): + mapping = pd.DataFrame({"relationship_id": ["maps to"]}) + + with pytest.raises(RuntimeError, match="relationship vocabulary is empty"): + _filter_unknown_relationship_ids(mapping, set()) + + +def test_filter_fails_when_no_relationship_mapping_survives(): + mapping = pd.DataFrame({"relationship_id": ["unknown relationship"]}) + + with pytest.raises(RuntimeError, match="None of the relationship mappings match"): + _filter_unknown_relationship_ids(mapping, {"maps to"}) diff --git a/tests/test_schema_provenance_guard.py b/tests/test_schema_provenance_guard.py new file mode 100644 index 0000000..98acaa3 --- /dev/null +++ b/tests/test_schema_provenance_guard.py @@ -0,0 +1,54 @@ +"""Schema-provenance enforcement reaching relationship_classification()'s +production/CLI path (engine=None). + +Monkeypatches omop_graph.cli.resolve_cdm_database (the one call site) rather +than the whole oa-configurator config chain, to point at a real, isolated +Postgres schema without touching the active on-disk config. + +The test first runs classification on one scoped schema, then reconfigures the +primary schema and verifies that calling relationship_classification() raises +SchemaDriftError while its session engines are being built. + +scoped_test_schema() commits real schemas, so every registry row this test +writes is a genuine commit. The Role-tag rows are reset around the test. +""" + +from __future__ import annotations + +import pytest +from oa_configurator import Role, SchemaDriftError +from oa_configurator.testing import ( + guarded_resolver, + reset_schema_registry_rows, + resolve_with_role_schemas, + scoped_test_schema, +) + +from orm_loader.helpers import Base + +from omop_graph import cli as omop_graph_cli +from fixtures.mock_cdm import seed_relationship_vocabulary + +pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] + + +def test_relationship_classification_guard_fires_on_reconfigured_schema( + pg_db, monkeypatch, cleanup_after_test +): + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine) + resolver = guarded_resolver(pg_db.resolved) + resolved = resolver.resolve_database(pg_db.resolved.name) + + with scoped_test_schema(resolved, prefix="graph_guard_a", resolver=resolver) as scoped_a: + Base.metadata.create_all(bind=scoped_a.engine, checkfirst=True) + seed_relationship_vocabulary(scoped_a.engine) + monkeypatch.setattr(omop_graph_cli, "resolve_cdm_database", lambda: scoped_a.resolved) + omop_graph_cli.relationship_classification() + + drifted = resolve_with_role_schemas( + scoped_a.resolved, + {Role.PRIMARY: "graph_guard_b"}, + resolver=resolver, + ) + with pytest.raises(SchemaDriftError): + omop_graph_cli.relationship_classification(resolved=drifted) diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py new file mode 100644 index 0000000..1de901c --- /dev/null +++ b/tests/test_vocab_split_connection.py @@ -0,0 +1,408 @@ +"""Split-connection vocab routing. + +KnowledgeGraph reads vocabulary rows through its routed sessions and +classifies them from the relationship mapping read at construction, so no +query joins the vocabulary database against RelationshipMapping on primary. + +Uses two real, distinct Postgres connections (test_cdm, test_orm) standing +in for primary/vocab servers, each with its own schema via +isolated_test_schema(). Note: rollback-based isolation can't stand in for two +genuinely separate physical connections. +""" + +from __future__ import annotations + +from datetime import date +from typing import Iterator, NamedTuple + +import pytest +import sqlalchemy as sa +import sqlalchemy.orm as so + +from oa_configurator import ResolvedCDMDatabase, Resolver, Role +from oa_configurator.testing import isolated_test_database, resolve_with_role_schemas, scoped_test_schema +from orm_loader.config import OrmLoaderConfig +from orm_loader.helpers import Base, bulk_load_context + +from omop_alchemy.cdm.model.vocabulary import ( + Concept, + Concept_Class, + Concept_Relationship, + Domain, + Relationship, + Vocabulary, +) +from omop_alchemy.cross_database import cdm_sessionmaker + +from omop_graph.cli import relationship_classification +from omop_graph.config import OmopGraphConfig +from omop_graph.extensions.omop_alchemy import ( + PredicateKind, + RelationshipClass, + RelationshipMapping, +) +from omop_graph.graph.kg import KnowledgeGraph + +from fixtures.helpers import VOCAB_TABLES + +pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] + +META_CONCEPT_ID = 0 +SUBJECT_CONCEPT_ID = 1 +OBJECT_CONCEPT_ID = 2 +_TODAY = date(2020, 1, 1) +_FAR_FUTURE = date(2099, 12, 31) + +# Relationship/Concept_Relationship aren't in the shared minimal vocab +# bootstrap, but are exactly what the split-connection merge is testing. +_VOCAB_TABLES = VOCAB_TABLES + (Relationship.__table__, Concept_Relationship.__table__) + +# Postgres has no cross-database inline FK, so RelationshipMapping's FK to +# relationship.relationship_id can't be created as DDL across genuinely +# separate databases. These shadow tables reproduce the real columns +# without it, since a SELECT/INSERT only depends on column shape. +_shadow_metadata = sa.MetaData() +_shadow_relationship_class = sa.Table( + "relationship_class", + _shadow_metadata, + sa.Column( + "predicate_kind", + sa.Enum( + PredicateKind, + values_callable=lambda obj: [e.value for e in obj], + schema=Role.PRIMARY.value, + ), + primary_key=True, + ), + sa.Column("predicate_subkind", sa.String(20), primary_key=True), + sa.Column("description", sa.String(80), nullable=False), + sa.Column("semantics", sa.String(40), nullable=False), + sa.Column("inference", sa.String(40), nullable=False), + # Must match RelationshipClass's schema, or SQLAlchemy silently builds a second, unlinked Table object. + schema=Role.PRIMARY.value, +) +_shadow_relationship_mapping = sa.Table( + "relationship_mapping", + _shadow_metadata, + sa.Column("relationship_id", sa.String(20), primary_key=True), + sa.Column( + "predicate_kind", + sa.Enum( + PredicateKind, + values_callable=lambda obj: [e.value for e in obj], + schema=Role.PRIMARY.value, + ), + primary_key=True, + ), + sa.Column("predicate_subkind", sa.String(20), primary_key=True), + schema=Role.PRIMARY.value, +) + + +class _Engines(NamedTuple): + primary: sa.Engine + vocab: sa.Engine + resolved: ResolvedCDMDatabase + + +@pytest.fixture() +def split_engines() -> Iterator[_Engines]: + """A primary connection (RelationshipMapping/RelationshipClass) and a + genuinely separate physical vocab connection (Relationship/Concept/ + Concept_Relationship), built by ``ResolvedCDMDatabase.create_engines()``.""" + with ( + isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as primary_db, + isolated_test_database(OrmLoaderConfig, "test_orm_db_pg") as vocab_db, + ): + name = primary_db.resolved.name + resolver = Resolver.from_active_config() + resolver = resolver.with_overrides( + databases={ + name: resolver.config.databases[name].model_copy( + update={"vocab_connection": vocab_db.resolved.connection.name} + ) + } + ) + split = resolver.resolve_database(name) + with scoped_test_schema(split, prefix="vocab_split", resolver=resolver) as scoped: + primary_engine, vocab_engine = scoped.resolved.create_engines() + # Vocab tables are created through the vocab server's own resolved: + # create_all on the split vocab engine also emits the PRIMARY-tagged + # predicatekind type, whose schema only exists on the primary server. + vocab_schema = scoped.schemas[Role.VOCAB] + vocab_only = resolve_with_role_schemas( + vocab_db.resolved, {role: vocab_schema for role in vocab_db.resolved.schema_tags()} + ) + vocab_setup_engine, _ = vocab_only.create_engines() + try: + _shadow_metadata.create_all(primary_engine) + Base.metadata.create_all(vocab_setup_engine, tables=_VOCAB_TABLES, checkfirst=True) + _seed(primary_engine, vocab_setup_engine) + yield _Engines(primary=primary_engine, vocab=vocab_engine, resolved=scoped.resolved) + finally: + vocab_setup_engine.dispose() + primary_engine.dispose() + vocab_engine.dispose() + + +def _seed(primary_engine: sa.Engine, vocab_engine: sa.Engine) -> None: + with so.Session(vocab_engine) as session: + with bulk_load_context(session): + session.add_all( + [ + Concept( + concept_id=META_CONCEPT_ID, + concept_name="Meta concept", + domain_id="Metadata", + vocabulary_id="OMOP", + concept_class_id="Metadata", + standard_concept="S", + concept_code="META", + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + ), + Concept( + concept_id=SUBJECT_CONCEPT_ID, + concept_name="Subject concept", + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept="S", + concept_code="SUBJ", + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + ), + Concept( + concept_id=OBJECT_CONCEPT_ID, + concept_name="Object concept", + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept="S", + concept_code="OBJ", + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + ), + Domain(domain_id="Metadata", domain_name="Metadata", domain_concept_id=META_CONCEPT_ID), + Domain(domain_id="Condition", domain_name="Condition", domain_concept_id=META_CONCEPT_ID), + Vocabulary( + vocabulary_id="OMOP", + vocabulary_name="OMOP", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=META_CONCEPT_ID, + ), + Vocabulary( + vocabulary_id="SNOMED", + vocabulary_name="SNOMED", + vocabulary_reference="local", + vocabulary_version="test", + vocabulary_concept_id=META_CONCEPT_ID, + ), + Concept_Class( + concept_class_id="Metadata", + concept_class_name="Metadata", + concept_class_concept_id=META_CONCEPT_ID, + ), + Concept_Class( + concept_class_id="Clinical Finding", + concept_class_name="Clinical Finding", + concept_class_concept_id=META_CONCEPT_ID, + ), + Relationship( + relationship_id="maps to", + relationship_name="Maps to", + is_hierarchical="0", + defines_ancestry="0", + reverse_relationship_id="mapped from", + relationship_concept_id=META_CONCEPT_ID, + ), + Relationship( + relationship_id="mapped from", + relationship_name="Mapped from", + is_hierarchical="0", + defines_ancestry="0", + reverse_relationship_id="maps to", + relationship_concept_id=META_CONCEPT_ID, + ), + Concept_Relationship( + concept_id_1=SUBJECT_CONCEPT_ID, + concept_id_2=OBJECT_CONCEPT_ID, + relationship_id="maps to", + valid_start_date=_TODAY, + valid_end_date=_FAR_FUTURE, + invalid_reason=None, + ), + ] + ) + session.flush() + session.commit() + + with so.Session(primary_engine) as session: + session.add_all( + [ + RelationshipClass( + predicate_kind=PredicateKind.IDENTITY, + predicate_subkind="mapping", + description="Identity mapping", + semantics="identity", + inference="none", + ), + RelationshipMapping( + relationship_id="maps to", + predicate_kind=PredicateKind.IDENTITY, + predicate_subkind="mapping", + ), + RelationshipMapping( + relationship_id="mapped from", + predicate_kind=PredicateKind.IDENTITY, + predicate_subkind="mapping", + ), + ] + ) + session.commit() + + +def _split_kg(engines: _Engines) -> KnowledgeGraph: + return KnowledgeGraph( + cdm_sessionmaker(engines.resolved, primary=engines.primary, vocab=engines.vocab) + ) + + +def test_predicate_merges_across_split_connections(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + predicate = kg.predicate("maps to") + + assert predicate.relationship_id == "maps to" + assert predicate.reverse_id == "mapped from" + assert predicate.predicate_kind == PredicateKind.IDENTITY + assert predicate.predicate_subkind == "mapping" + + +def test_predicates_merges_across_split_connections(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + by_id = {p.relationship_id: p for p in kg.predicates()} + + assert set(by_id) == {"maps to", "mapped from"} + assert by_id["maps to"].predicate_kind == PredicateKind.IDENTITY + assert by_id["maps to"].predicate_subkind == "mapping" + + +def test_edges_merges_across_split_connections(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + edges = kg.edges( + concept_ids=SUBJECT_CONCEPT_ID, + direction="out", + active_only=False, + within_domain=False, + ) + + assert len(edges) == 1 + edge = edges[0] + assert edge.subject_id == SUBJECT_CONCEPT_ID + assert edge.object_id == OBJECT_CONCEPT_ID + assert edge.predicate_id == "maps to" + assert edge.predicate_kind == PredicateKind.IDENTITY + assert edge.predicate_subkind == "mapping" + + +def test_edges_predicate_kinds_filter_applies_after_merge(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + edges = kg.edges( + concept_ids=SUBJECT_CONCEPT_ID, + direction="out", + active_only=False, + within_domain=False, + predicate_kinds=frozenset({PredicateKind.HIERARCHY}), + ) + + assert edges == () + + +def test_concept_view_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + view = kg.concept_view(SUBJECT_CONCEPT_ID) + + assert view.concept_id == SUBJECT_CONCEPT_ID + assert view.concept_name == "Subject concept" + + +def test_concept_id_by_code_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + + assert kg.concept_id_by_code("SNOMED", "SUBJ") == SUBJECT_CONCEPT_ID + + +def test_concept_ids_by_label_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + + assert kg.concept_ids_by_label("Subject concept") == (SUBJECT_CONCEPT_ID,) + + +def test_predicate_name_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + + assert kg.predicate_name("maps to") == "Maps to" + + +def test_valid_domains_and_vocabularies_resolve_against_the_vocab_connection( + split_engines: _Engines, +) -> None: + kg = _split_kg(split_engines) + + assert {"Metadata", "Condition"} <= kg._valid_domains + assert {"OMOP", "SNOMED"} <= kg._valid_vocabularies + + +def test_entities_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + ids = tuple(kg.entities(domain="Condition")) + + assert set(ids) == {SUBJECT_CONCEPT_ID, OBJECT_CONCEPT_ID} + + +def test_relationships_resolves_against_the_vocab_connection(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + triples = tuple(kg.relationships(subjects=(SUBJECT_CONCEPT_ID,), predicates=None, objects=None)) + + assert triples == ((SUBJECT_CONCEPT_ID, "maps to", OBJECT_CONCEPT_ID),) + + +def test_relationships_invert_swaps_subjects_and_objects(split_engines: _Engines) -> None: + kg = _split_kg(split_engines) + triples = tuple( + kg.relationships(subjects=(OBJECT_CONCEPT_ID,), predicates=None, objects=None, invert=True) + ) + + assert triples == ((OBJECT_CONCEPT_ID, "maps to", SUBJECT_CONCEPT_ID),) + + +def test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection( + split_engines: _Engines, tmp_path +) -> None: + """relationship_classification() creates RelationshipMapping without its + FK to the vocabulary. A relationship ID the vocabulary lacks is warned + about and dropped.""" + (tmp_path / "predicate_classification.csv").write_text( + "class,subclass,description,semantics,inference\n" + "Identity,mapping,Identity mapping,identity,none\n" + ) + (tmp_path / "predicate_mapping.csv").write_text( + "class,subclass,r_id,r_name,classification_notes,additional_info,link\n" + "Identity,mapping,maps to,Maps to,,,\n" + "Identity,mapping,unknown_rel,Unknown,,,\n" + ) + + relationship_classification(str(tmp_path), resolved=split_engines.resolved) + + with so.Session(split_engines.primary) as session: + relationship_ids = set( + session.execute(sa.select(RelationshipMapping.relationship_id)).scalars() + ) + assert relationship_ids == {"maps to"} + + kg = _split_kg(split_engines) + assert "unknown_rel" not in {p.relationship_id for p in kg.predicates()} + predicate = kg.predicate("maps to") + assert predicate.predicate_kind == PredicateKind.IDENTITY + assert predicate.predicate_subkind == "mapping" diff --git a/uv.lock b/uv.lock index ba40afe..6b32e7f 100644 --- a/uv.lock +++ b/uv.lock @@ -54,7 +54,8 @@ name = "anthropic" version = "0.121.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "docstring-parser" }, { name = "httpx" }, @@ -103,15 +104,40 @@ ollama = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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'", + "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'", +] dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or python_full_version >= '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -862,7 +888,8 @@ name = "google-genai" version = "2.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "google-auth", extra = ["requests"] }, { name = "httpx" }, @@ -920,9 +947,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -930,9 +955,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -940,9 +963,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -950,18 +971,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -969,9 +986,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -1075,7 +1090,8 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -1813,7 +1829,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -1828,9 +1844,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, ] [[package]] @@ -2013,7 +2029,7 @@ wheels = [ [[package]] name = "oa-configurator" -version = "1.0.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -2022,10 +2038,11 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tomli-w" }, { name = "typer" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/bf/8934b30a7ffb24b17dd05fe39a85cf30d47123e3cbe093ce964797a33a43/oa_configurator-1.0.0.tar.gz", hash = "sha256:5ab2a0ba7d2b723312e02422a937047a792c3cba1c4ae59fbd4df53ec0955209", size = 156038, upload-time = "2026-08-11T12:58:39.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/62/64bf398b8fe381d138446199be69bc437ef93fdb70aab48cebf51467f6ea/oa_configurator-2.0.0.tar.gz", hash = "sha256:26406e90aeadd4c747646763b80f16025c4c6638afec687517788bc537d0d581", size = 278382, upload-time = "2026-10-10T22:48:42.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/84/30fd96eb7a83e9e6ef0d911e402002c3ece45541a8d7892be2799152b114/oa_configurator-1.0.0-py3-none-any.whl", hash = "sha256:9e3ae9e904bdf92201c22df851b8bcc47ad66f206a8fafcd79875b7b27670beb", size = 52209, upload-time = "2026-08-11T12:58:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/99f0073d97de3545d48cf28c8f39b724d543baf83e8af8d0ba5c8bffbb01/oa_configurator-2.0.0-py3-none-any.whl", hash = "sha256:2186beb5567850ba5bfe4950c2820b909d3746c6fa233a6074a85396a343af35", size = 118027, upload-time = "2026-10-10T22:48:40.571Z" }, ] [package.optional-dependencies] @@ -2104,7 +2121,7 @@ wheels = [ [[package]] name = "omop-alchemy" -version = "1.1.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "oa-configurator" }, @@ -2114,10 +2131,11 @@ dependencies = [ { name = "rich" }, { name = "sqlalchemy" }, { name = "typer" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/b6/315c72cba081c4cb5fed6737155047667e9057b92307579200551953e248/omop_alchemy-1.1.0.tar.gz", hash = "sha256:d78905132ad0f8933d8d6fceccbac903f497d40a54634ec9193023b237bb5afb", size = 395165, upload-time = "2026-08-24T06:19:41.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/4b/a925c1db558070a4a807a38456a90dab33ab766e18cd3de890dae94295ca/omop_alchemy-2.0.0.tar.gz", hash = "sha256:403848b2c377ef6200119f4d5280a1b7e1abe0c42e8681fe16a74ee87fa82259", size = 734827, upload-time = "2026-10-10T23:39:29.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/2c/c51519b2c1ff3b71b1117ca5e829451cbbe0d77d404d3ff8b485bd3e989c/omop_alchemy-1.1.0-py3-none-any.whl", hash = "sha256:31b64099145a6183fb71d6d89ecd662a506b5e9f9ab74b0b117860e1085991eb", size = 233776, upload-time = "2026-08-24T06:19:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d2/2c271ce7d09b978bf812b698baf9000f1bc56afa2d1915b58c9b079875db/omop_alchemy-2.0.0-py3-none-any.whl", hash = "sha256:17cdbf3d901f23da339b7e4d4598f22f178499733fa326884de6e0de40c78f02", size = 298023, upload-time = "2026-10-10T23:39:28.182Z" }, ] [package.optional-dependencies] @@ -2127,7 +2145,7 @@ postgres = [ [[package]] name = "omop-emb" -version = "2.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, @@ -2142,9 +2160,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/4d/ea6d56dc541327c0d722d9a07f5d50a31f6ce05916eb0e969145bde428b1/omop_emb-2.0.0.tar.gz", hash = "sha256:0d37902279de396ac53fc177756dd271e06b5c4e5c0a9016678eb53e64703372", size = 228344, upload-time = "2026-08-12T13:17:41.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/68/54b0a9538da687f38e995d16fb335d585cd7dbca423be5874fb3f07217ae/omop_emb-3.0.0.tar.gz", hash = "sha256:6ceabae683e9f36cc9250fb0a62a6074a588b5ff5594e8fd8b06c1dcec5a826e", size = 249683, upload-time = "2026-10-11T00:20:20.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/7a/ff447676f39352295672b269dfc0ce24d94cf0aaec0cd8efcbe801bb30fe/omop_emb-2.0.0-py3-none-any.whl", hash = "sha256:4302ab0c6e69d388c43dcf8e714359ca3e0a1fc3f86c01aef9e77a778f1ba2c6", size = 89113, upload-time = "2026-08-12T13:17:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/58/a6/e564d7d43b294c592a93f1da737a7e836cb50f592a9127e6b10ef4383471/omop_emb-3.0.0-py3-none-any.whl", hash = "sha256:0e78c7c0ef01a71d1a62498d9fab494aab81d7fa40aa1e712ec097530c5df730", size = 94080, upload-time = "2026-10-11T00:20:18.782Z" }, ] [package.optional-dependencies] @@ -2209,19 +2227,19 @@ requires-dist = [ { name = "mkdocs-mermaid2-plugin", marker = "extra == 'dev'" }, { name = "mkdocs-table-reader-plugin", marker = "extra == 'dev'" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'" }, - { name = "oa-configurator", specifier = ">=1.0.0,<2.0.0" }, - { name = "oa-configurator", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" }, + { name = "oa-configurator", specifier = ">=2.0.0,<3" }, + { name = "oa-configurator", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=2.0.0,<3" }, { name = "oaklib" }, - { name = "omop-alchemy", specifier = ">=1.0.0,<2.0.0" }, - { name = "omop-alchemy", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" }, - { name = "omop-alchemy", extras = ["postgres"], marker = "extra == 'postgres'", specifier = ">=1.0.0,<2.0.0" }, - { name = "omop-emb", marker = "extra == 'dev'", specifier = ">=2.0.0,<3.0.0" }, - { name = "omop-emb", marker = "extra == 'emb'", specifier = ">=2.0.0,<3.0.0" }, - { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'dev'", specifier = ">=2.0.0,<3.0.0" }, - { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'faiss-cpu'", specifier = ">=2.0.0,<3.0.0" }, - { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'dev'", specifier = ">=2.0.0,<3.0.0" }, - { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'pgvector'", specifier = ">=2.0.0,<3.0.0" }, - { name = "orm-loader", specifier = ">=1.0.0,<2.0.0" }, + { name = "omop-alchemy", specifier = ">=2.0.0,<3" }, + { name = "omop-alchemy", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=2.0.0,<3" }, + { name = "omop-alchemy", extras = ["postgres"], marker = "extra == 'postgres'", specifier = ">=2.0.0,<3" }, + { name = "omop-emb", marker = "extra == 'dev'", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", marker = "extra == 'emb'", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'dev'", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'faiss-cpu'", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'dev'", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'pgvector'", specifier = ">=3.0.0,<4" }, + { name = "orm-loader", specifier = ">=2.0.0,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "rich", specifier = ">=13.0" }, @@ -2235,7 +2253,7 @@ provides-extras = ["dev", "emb", "faiss-cpu", "pgvector", "postgres"] [[package]] name = "omop-llm" -version = "1.0.1" +version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "any-llm-sdk", extra = ["gemini", "ollama"] }, @@ -2244,9 +2262,9 @@ dependencies = [ { name = "ollama" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/ae/dec515aac6273c562b2edd2bea0b996d40a65092213907e2eeb09098fc46/omop_llm-1.0.1.tar.gz", hash = "sha256:cebdb7df66759f516198477c97e882534d8700787b0bae001b43a8f8071be964", size = 183148, upload-time = "2026-08-12T12:23:46.817Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/92/2147d62967adb61e91a5474ca0278bb3905be394718aad9c4d6427ba0196/omop_llm-1.0.2.tar.gz", hash = "sha256:a80267b95b1f1ad76e41a2285030d68d0c13825216dc7363f6eb87647c83c599", size = 182158, upload-time = "2026-10-11T00:10:42.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/14/fc6782952879b08d02bd64c5b7fd1114465d826196681fd69a69e8207db0/omop_llm-1.0.1-py3-none-any.whl", hash = "sha256:aecfa1578286c3fad8f61c33032bd63355f8079ee5915ad2941a87e87cd3796a", size = 25863, upload-time = "2026-08-12T12:23:45.81Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b4/fe6d24752cf7c472a0473f7f484660bc57a7779d29ae1669d387ae76b668/omop_llm-1.0.2-py3-none-any.whl", hash = "sha256:7dbd72df14fccefcf1be09fbcea89b8870c944c91db3dd686bacdcb1bcb544cc", size = 25864, upload-time = "2026-10-11T00:10:41.434Z" }, ] [[package]] @@ -2270,7 +2288,8 @@ name = "openai" version = "2.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "httpx" }, { name = "jiter" }, @@ -2310,7 +2329,7 @@ wheels = [ [[package]] name = "orm-loader" -version = "1.0.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -2319,9 +2338,9 @@ dependencies = [ { name = "pyarrow" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/f5/d71344e3f65dc29021e3e0b59d7ebfbd2070b11f8c9224cdb891278dc649/orm_loader-1.0.0.tar.gz", hash = "sha256:171969195afec304398ef8e3cceab52dba4b082347d0dc84feefff10b81bed52", size = 136810, upload-time = "2026-08-12T04:49:18.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/cd/b3a29d383b80e1c282a3ee12a28f00b5608802763dd98ed7b3adc637e630/orm_loader-2.0.0.tar.gz", hash = "sha256:73232ecb5f1474738e54f30fae89999620d57cc41456ae799669a74c690a76b3", size = 171586, upload-time = "2026-10-10T23:22:15.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f8/952d95e9e6573787cd30f276604b1c2aad166e1922b9e486fff516a48919/orm_loader-1.0.0-py3-none-any.whl", hash = "sha256:3212894544fe1ae5e0f9e88ebf873addff9854726ec70a84f9140e39e591d68a", size = 56790, upload-time = "2026-08-12T04:49:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/6687b9c1602deb1c364d4653c641698e3de298b9c4f5dc98c59698455ac7/orm_loader-2.0.0-py3-none-any.whl", hash = "sha256:a118ddc3fcb611d658b7be52f616b641804ae65067efd05753d1bc14a03b52c9", size = 74959, upload-time = "2026-10-10T23:22:14.645Z" }, ] [[package]] @@ -2426,7 +2445,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -3753,11 +3772,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]]