From 6b0865101fdbc2505eb49ec8a14423e0e0b53935 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 14:40:03 +1000 Subject: [PATCH 1/7] starting handlers folder reorganisation --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 724d7d2..9dc5ad5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ keywords = [ ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Intended Audience :: Healthcare Industry", "Topic :: Scientific/Engineering :: Medical Science Apps.", From 459ebcf60281782df6eb0847a1cdb087f1067a80 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 14:46:33 +1000 Subject: [PATCH 2/7] fixing ignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 92ea8da..1002bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ temp/ *.bak notebooks/ .dockerignore -docker/ \ No newline at end of file +docker/ +tests/test_meds* \ No newline at end of file From 27f0c32044ce6c179d94c68c40453a4d5232b073 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 16:34:04 +1000 Subject: [PATCH 3/7] moving handlers - base --- .importlinter | 12 + docs/advanced/timelines.md | 20 +- docs/api/typing.md | 11 +- omop_alchemy/cdm/base/__init__.py | 2 + .../concept_validation.py} | 47 +- omop_alchemy/cdm/base/modifier_interface.py | 12 +- omop_alchemy/cdm/base/typing.py | 7 +- omop_alchemy/cdm/handlers/__init__.py | 6 - .../cdm/handlers/timeline/__init__.py | 5 - .../handlers/vocabs_and_mappers/__init__.py | 16 - .../model/clinical/procedure_occurrence.py | 13 + omop_alchemy/cdm/model/structural/__init__.py | 10 +- omop_alchemy/cdm/model/structural/episode.py | 19 +- .../cdm/model/structural/episode_event.py | 76 +- omop_alchemy/toolkit/__init__.py | 35 + omop_alchemy/toolkit/analytics/__init__.py | 25 + .../analytics/adverse_events/__init__.py | 31 + .../analytics/body_metrics/__init__.py | 43 + .../toolkit/analytics/oncology/__init__.py | 44 + omop_alchemy/toolkit/core/__init__.py | 22 + omop_alchemy/toolkit/core/_semantics.py | 22 + .../toolkit/core/concepts/__init__.py | 69 + .../core/concepts/lookup.py} | 6 +- .../core/concepts/normalizers.py} | 2 +- .../core/concepts/registry.py} | 2 +- .../toolkit/core/timeline/__init__.py | 45 + .../core}/timeline/event_timeline.py | 2 +- omop_alchemy/toolkit/core/units/__init__.py | 36 + omop_alchemy/toolkit/core/units/body_units.py | 101 ++ omop_alchemy/toolkit/episodes/__init__.py | 24 + .../toolkit/episodes/derivation/__init__.py | 21 + .../toolkit/episodes/handling/__init__.py | 52 + .../episodes/handling/resolved_event.py | 139 ++ omop_alchemy/toolkit/integrations/__init__.py | 15 + .../integrations/meds_standard/__init__.py | 44 + pyproject.toml | 7 +- tests/test_concept_validation.py | 118 ++ tests/test_episodes_basic.py | 118 +- uv.lock | 1274 ++++++++++++++++- 39 files changed, 2449 insertions(+), 104 deletions(-) create mode 100644 .importlinter rename omop_alchemy/cdm/{handlers/vocabs_and_mappers/concept_resolver.py => base/concept_validation.py} (79%) delete mode 100644 omop_alchemy/cdm/handlers/__init__.py delete mode 100644 omop_alchemy/cdm/handlers/timeline/__init__.py delete mode 100644 omop_alchemy/cdm/handlers/vocabs_and_mappers/__init__.py create mode 100644 omop_alchemy/toolkit/__init__.py create mode 100644 omop_alchemy/toolkit/analytics/__init__.py create mode 100644 omop_alchemy/toolkit/analytics/adverse_events/__init__.py create mode 100644 omop_alchemy/toolkit/analytics/body_metrics/__init__.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/__init__.py create mode 100644 omop_alchemy/toolkit/core/__init__.py create mode 100644 omop_alchemy/toolkit/core/_semantics.py create mode 100644 omop_alchemy/toolkit/core/concepts/__init__.py rename omop_alchemy/{cdm/handlers/vocabs_and_mappers/vocab_handlers.py => toolkit/core/concepts/lookup.py} (98%) rename omop_alchemy/{cdm/handlers/vocabs_and_mappers/concept_normalisers.py => toolkit/core/concepts/normalizers.py} (96%) rename omop_alchemy/{cdm/handlers/vocabs_and_mappers/concept_registry.py => toolkit/core/concepts/registry.py} (97%) create mode 100644 omop_alchemy/toolkit/core/timeline/__init__.py rename omop_alchemy/{cdm/handlers => toolkit/core}/timeline/event_timeline.py (98%) create mode 100644 omop_alchemy/toolkit/core/units/__init__.py create mode 100644 omop_alchemy/toolkit/core/units/body_units.py create mode 100644 omop_alchemy/toolkit/episodes/__init__.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/__init__.py create mode 100644 omop_alchemy/toolkit/episodes/handling/__init__.py create mode 100644 omop_alchemy/toolkit/episodes/handling/resolved_event.py create mode 100644 omop_alchemy/toolkit/integrations/__init__.py create mode 100644 omop_alchemy/toolkit/integrations/meds_standard/__init__.py create mode 100644 tests/test_concept_validation.py diff --git a/.importlinter b/.importlinter new file mode 100644 index 0000000..1078a5d --- /dev/null +++ b/.importlinter @@ -0,0 +1,12 @@ +[importlinter] +root_package = omop_alchemy + +[importlinter:contract:toolkit-layers] +name = Toolkit tiers are strictly layered, cdm is the schema they are built on +type = layers +layers = + omop_alchemy.toolkit.integrations + omop_alchemy.toolkit.analytics + omop_alchemy.toolkit.episodes + omop_alchemy.toolkit.core + omop_alchemy.cdm \ No newline at end of file diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index 2956a6e..c18a16d 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -15,7 +15,7 @@ production query pipelines where raw SQLAlchemy queries are more appropriate. A canonical temporal representation. Every clinical event has a start datetime; an end datetime is optional. The `kind` property returns `"point"` or `"interval"`. -::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventTime +::: omop_alchemy.toolkit.core.timeline.event_timeline.EventTime --- @@ -23,7 +23,7 @@ datetime is optional. The `kind` property returns `"point"` or `"interval"`. The value associated with a clinical event — numeric, concept, string, or none. -::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventValue +::: omop_alchemy.toolkit.core.timeline.event_timeline.EventValue --- @@ -33,7 +33,7 @@ Declares which ORM fields supply the concept, start/end datetimes, and value for particular CDM table. Subclasses of `ClinicalEvent` set `_mapping` to an `EventMapping` instance at class level. -::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventMapping +::: omop_alchemy.toolkit.core.timeline.event_timeline.EventMapping --- @@ -43,7 +43,7 @@ instance at class level. `_mapping` to implement `event_time`, `event_value`, `event_metadata`, `to_dict`, and `to_json`. -::: omop_alchemy.cdm.handlers.timeline.event_timeline.ClinicalEvent +::: omop_alchemy.toolkit.core.timeline.event_timeline.ClinicalEvent --- @@ -57,11 +57,11 @@ Three CDM tables are pre-wired with `EventMapping`s: | `Measurement_Event` | `measurement` | `measurement_concept_id` | `value_as_number`, `value_as_concept_id`, `value_as_string` | | `Drug_Exposure_Event` | `drug_exposure` | `drug_concept_id` | `quantity` | -::: omop_alchemy.cdm.handlers.timeline.event_timeline.Condition_Event +::: omop_alchemy.toolkit.core.timeline.event_timeline.Condition_Event -::: omop_alchemy.cdm.handlers.timeline.event_timeline.Measurement_Event +::: omop_alchemy.toolkit.core.timeline.event_timeline.Measurement_Event -::: omop_alchemy.cdm.handlers.timeline.event_timeline.Drug_Exposure_Event +::: omop_alchemy.toolkit.core.timeline.event_timeline.Drug_Exposure_Event --- @@ -71,7 +71,7 @@ Extends the `Person` ORM class with `.events` and `.timeline` properties. Requir active SQLAlchemy session (i.e. the object must have been loaded from a session, not constructed in memory). -::: omop_alchemy.cdm.handlers.timeline.event_timeline.Person_Timeline +::: omop_alchemy.toolkit.core.timeline.event_timeline.Person_Timeline --- @@ -79,7 +79,7 @@ constructed in memory). ```python from sqlalchemy.orm import Session -from omop_alchemy.cdm.handlers.timeline import Person_Timeline +from omop_alchemy.toolkit.core.timeline import Person_Timeline with Session(engine) as session: person = session.get(Person_Timeline, 42) @@ -96,7 +96,7 @@ To add a new CDM table to the timeline, subclass both `ClinicalEvent` and the ta class and set `_mapping`: ```python -from omop_alchemy.cdm.handlers.timeline.event_timeline import ClinicalEvent, EventMapping +from omop_alchemy.toolkit.core.timeline.event_timeline import ClinicalEvent, EventMapping from omop_alchemy.cdm.model.clinical import Procedure_Occurrence class Procedure_Event(Procedure_Occurrence, ClinicalEvent): diff --git a/docs/api/typing.md b/docs/api/typing.md index 40e00dc..57b4839 100644 --- a/docs/api/typing.md +++ b/docs/api/typing.md @@ -72,7 +72,7 @@ utilities that operate across multiple CDM tables. !!! note The concrete mixin of the same name lives in - `omop_alchemy.cdm.handlers.timeline.event_timeline`. The Protocol here is the + `omop_alchemy.toolkit.core.timeline.event_timeline`. The Protocol here is the structural interface; the mixin there is the implementation. ::: omop_alchemy.cdm.base.typing.ClinicalEvent @@ -83,15 +83,6 @@ utilities that operate across multiple CDM tables. --- -### `ConceptResolver` - -Protocol for objects that can look up whether a set of concept IDs are standard. - -::: omop_alchemy.cdm.base.typing.ConceptResolver - options: - heading_level: 4 ---- - ## Typed row containers (`cdm.model.typing`) ### `ConceptRow` diff --git a/omop_alchemy/cdm/base/__init__.py b/omop_alchemy/cdm/base/__init__.py index fb76662..246c737 100644 --- a/omop_alchemy/cdm/base/__init__.py +++ b/omop_alchemy/cdm/base/__init__.py @@ -4,6 +4,7 @@ from .column_mixins import ValueMixin, ReferenceTable, DatedEvent, PersonScoped, HealthSystemContext, FactTable from .indexing import merge_table_args, omop_index, omop_primary_key_index_name, omop_table_options from .domain_validation import DomainValidationMixin, DomainRule, ExpectedDomain +from .concept_validation import ConceptValidationMixin from .reference_context import ReferenceContext from .typing import HasConceptId, HasEpisodeId, HasPersonId, DomainSemanticTable, ClinicalEvent from .modifier_interface import ModifierTargetMixin @@ -29,6 +30,7 @@ "PersonScoped", "HealthSystemContext", "DomainValidationMixin", + "ConceptValidationMixin", "FactTable", "merge_table_args", "ModifierTargetMixin", diff --git a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_resolver.py b/omop_alchemy/cdm/base/concept_validation.py similarity index 79% rename from omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_resolver.py rename to omop_alchemy/cdm/base/concept_validation.py index 30a38b6..a609b30 100644 --- a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_resolver.py +++ b/omop_alchemy/cdm/base/concept_validation.py @@ -1,30 +1,21 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from ...model.vocabulary.concept import Concept +from typing import TYPE_CHECKING, cast +if TYPE_CHECKING: + from omop_alchemy.cdm.model.vocabulary.concept import Concept as ConceptModel -class OMOPConceptResolver: - def __init__(self, session): - self.session = session - def are_standard(self, concept_ids): - if not concept_ids: - return {} +def _concept_cls() -> "type[ConceptModel]": + # need to be able to query concept table but can't import directly here to avoid circular imports + from orm_loader.helpers import get_model_by_tablename - rows = ( - self.session.query( - Concept.concept_id, - Concept.standard_concept, - ) - .filter(Concept.concept_id.in_(set(concept_ids))) - .all() + ConceptCls: object = get_model_by_tablename("Concept") + if ConceptCls is None: + raise RuntimeError( + "Concept model is not registered; cannot validate referenced concepts." ) - - return { - cid: (std == "S") - for cid, std in rows - } - + return cast("type[ConceptModel]", ConceptCls) class ConceptValidationMixin: @@ -37,7 +28,7 @@ class ConceptValidationMixin: Works for: - ORM mapped tables - - materialized views + - materialised views - Core selectables """ @@ -58,7 +49,7 @@ def concept_id_columns(cls) -> dict[str, sa.ColumnElement]: return { c.key: c for c in cols - if c.key and c.key.endswith("_concept_id") and 'source' not in c.key + if c.key and c.key.endswith("_concept_id") and 'source' not in c.key } @@ -118,6 +109,7 @@ def _non_standard_concepts_for_column( sqlalchemy.Select A SELECT returning a single column: the violating concept_id. """ + Concept = _concept_cls() # Base join condition: concept_id match join_cond = Concept.concept_id == col @@ -135,7 +127,7 @@ def _non_standard_concepts_for_column( join_cond, Concept.vocabulary_id == vocabulary_id, ) - + from_clause = sa.outerjoin( table, Concept, @@ -149,8 +141,13 @@ def _non_standard_concepts_for_column( .where( col.is_not(None), sa.or_( - Concept.concept_id.is_(None), # missing concept - Concept.standard_concept.is_(None), # non-standard concept + # outer join left no concept row at all + Concept.concept_id.is_(None), + # Concept owns the definition of standard-ness, including + # tolerance for blank and whitespace-only flag values. + # is_not(True) rather than not_(): the expression is NULL + # for a NULL or blank flag, and NOT NULL would not match. + Concept.is_standard_expr().is_not(True), ), ) ) diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index 0fcf2ef..87f8c8a 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -2,6 +2,7 @@ from sqlalchemy.ext.hybrid import hybrid_property from typing import ClassVar,Optional from datetime import date +from sqlalchemy.sql.elements import SQLColumnExpression class ModifierTargetMixin: """ @@ -10,6 +11,7 @@ class ModifierTargetMixin: """ __abstract__ = True + __tablename__: ClassVar[str] __event_id_col__: ClassVar[str] __concept_id_col__: ClassVar[str] __start_date_col__: ClassVar[str] @@ -22,16 +24,18 @@ def modifier_field_concept_id(cls) -> int: @classmethod def modifier_target_table(cls) -> str: - return cls.__tablename__ # ty: ignore[unresolved-attribute] + return cls.__tablename__ @hybrid_property def event_id(self) -> int: return getattr(self, self.__event_id_col__) - @event_id.expression - def event_id(cls): + + @event_id.inplace.expression + @classmethod + def _event_id(cls) -> SQLColumnExpression[int]: return getattr(cls, cls.__event_id_col__) - + @property def concept_id(self) -> int: return getattr(self, self.__concept_id_col__) diff --git a/omop_alchemy/cdm/base/typing.py b/omop_alchemy/cdm/base/typing.py index 7640217..9b19272 100644 --- a/omop_alchemy/cdm/base/typing.py +++ b/omop_alchemy/cdm/base/typing.py @@ -1,4 +1,4 @@ -from typing import Protocol, ClassVar, runtime_checkable, TYPE_CHECKING, Optional, Iterable +from typing import Protocol, ClassVar, runtime_checkable, TYPE_CHECKING, Optional from sqlalchemy.orm import DeclarativeMeta from datetime import date @@ -40,8 +40,3 @@ class ClinicalEvent(Protocol): visit_occurrence_id: Optional[int] visit_detail_id: Optional[int] - - - -class ConceptResolver(Protocol): - def are_standard(self, concept_ids: Iterable[int]) -> dict[int, bool]:... \ No newline at end of file diff --git a/omop_alchemy/cdm/handlers/__init__.py b/omop_alchemy/cdm/handlers/__init__.py deleted file mode 100644 index a5963ee..0000000 --- a/omop_alchemy/cdm/handlers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .vocabs_and_mappers import make_concept_resolver, ConceptResolverRegistry - -__all__ = [ - "make_concept_resolver", - "ConceptResolverRegistry", -] diff --git a/omop_alchemy/cdm/handlers/timeline/__init__.py b/omop_alchemy/cdm/handlers/timeline/__init__.py deleted file mode 100644 index 48c7e23..0000000 --- a/omop_alchemy/cdm/handlers/timeline/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .event_timeline import Person_Timeline - -__all__ = [ - "Person_Timeline", -] \ No newline at end of file diff --git a/omop_alchemy/cdm/handlers/vocabs_and_mappers/__init__.py b/omop_alchemy/cdm/handlers/vocabs_and_mappers/__init__.py deleted file mode 100644 index 23eab89..0000000 --- a/omop_alchemy/cdm/handlers/vocabs_and_mappers/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .vocab_handlers import LookupIndex, LookupSpec, ConceptResolver, make_concept_resolver -from .concept_normalisers import compose_normalizers, normalize_default, strip_uicc, make_stage, site_to_NOS -from .concept_registry import ConceptResolverRegistry - -__all__ = [ - "LookupIndex", - "LookupSpec", - "ConceptResolver", - "make_concept_resolver", - "compose_normalizers", - "normalize_default", - "strip_uicc", - "make_stage", - "site_to_NOS", - "ConceptResolverRegistry", -] \ No newline at end of file diff --git a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py index e3d6c07..4dfb65b 100644 --- a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py +++ b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py @@ -15,6 +15,8 @@ ReferenceContext, DomainValidationMixin, ExpectedDomain, + ModifierTargetMixin, + ModifierFieldConcepts, merge_table_args, omop_index, ) @@ -111,10 +113,17 @@ class Procedure_OccurrenceView( Procedure_Occurrence, Procedure_OccurrenceContext, DomainValidationMixin, + ModifierTargetMixin, ): __tablename__ = "procedure_occurrence" __mapper_args__ = {"concrete": False} + __event_id_col__ = "procedure_occurrence_id" + __concept_id_col__ = "procedure_concept_id" + __start_date_col__ = "procedure_date" + __end_date_col__ = "procedure_end_date" + __type_concept_id_col__ = "procedure_type_concept_id" + __expected_domains__ = { "procedure_concept_id": ExpectedDomain("Procedure"), "procedure_type_concept_id": ExpectedDomain("Type Concept"), @@ -125,6 +134,10 @@ def start(self): """Best-effort start datetime.""" return self.procedure_datetime or self.procedure_date + @classmethod + def modifier_field_concept_id(cls) -> int: + return ModifierFieldConcepts.PROCEDURE_OCCURRENCE + @property def end(self): """Best-effort end datetime.""" diff --git a/omop_alchemy/cdm/model/structural/__init__.py b/omop_alchemy/cdm/model/structural/__init__.py index f75d93e..2002a44 100644 --- a/omop_alchemy/cdm/model/structural/__init__.py +++ b/omop_alchemy/cdm/model/structural/__init__.py @@ -1,5 +1,10 @@ from .episode import Episode, EpisodeContext, EpisodeView -from .episode_event import Episode_Event, Episode_EventContext, Episode_EventView +from .episode_event import ( + Episode_Event, + Episode_EventContext, + Episode_EventView, + clear_episode_event_target_class_cache, +) from .fact_relationship import Fact_Relationship __all__ = [ @@ -9,5 +14,6 @@ "Episode_Event", "Episode_EventContext", "Episode_EventView", + "clear_episode_event_target_class_cache", "Fact_Relationship", -] \ No newline at end of file +] diff --git a/omop_alchemy/cdm/model/structural/episode.py b/omop_alchemy/cdm/model/structural/episode.py index 0affd93..fcab297 100644 --- a/omop_alchemy/cdm/model/structural/episode.py +++ b/omop_alchemy/cdm/model/structural/episode.py @@ -1,7 +1,7 @@ import sqlalchemy as sa import sqlalchemy.orm as so from sqlalchemy.ext.declarative import declared_attr -from typing import Optional, TYPE_CHECKING, List +from typing import ClassVar, Optional, TYPE_CHECKING, List from datetime import date from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( @@ -56,6 +56,8 @@ def __repr__(self) -> str: return f"" class EpisodeContext(ReferenceContext): + __table__: ClassVar[sa.Table] + person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person",local_fk="person_id",remote_pk="person_id") # type: ignore[assignment] episode_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_concept_id",remote_pk="concept_id") # type: ignore[assignment] episode_object_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_object_concept_id",remote_pk="concept_id") # type: ignore[assignment] @@ -82,6 +84,21 @@ def parent_episode(cls) -> so.Mapped[Optional["Episode"]]: uselist=False, ) + @declared_attr + @classmethod + def children(cls) -> so.Mapped[List["Episode"]]: + episode_id = cls.__table__.c.episode_id + episode_parent_id = cls.__table__.c.episode_parent_id + return so.relationship( + cls.__name__, + primaryjoin=lambda: so.remote(episode_parent_id) == episode_id, + foreign_keys=lambda: [episode_parent_id], + remote_side=lambda: [episode_parent_id], + viewonly=True, + lazy="selectin", + uselist=True, + ) + class EpisodeView(Episode, EpisodeContext, DomainValidationMixin): """ Navigable Episode view. diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index ae3efb5..db6edab 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -1,14 +1,15 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from typing import TYPE_CHECKING, Any, Type, cast +from typing import TYPE_CHECKING, Any, Type from functools import cached_property -from orm_loader.helpers import Base, get_model_by_tablename +from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( cdm_table, - CDMTableBase, + CDMTableBase, ReferenceContext, DomainValidationMixin, ExpectedDomain, + ModifierTargetMixin, merge_table_args, omop_index, ) @@ -17,6 +18,48 @@ from ..vocabulary import Concept from .episode import Episode + +def _modifier_target_classes_by_field_concept_id() -> dict[int, Type[Any]]: + classes: dict[int, Type[Any]] = {} + for mapper in Base.registry.mappers: + cls = mapper.class_ + if not issubclass(cls, ModifierTargetMixin): + continue + try: + field_concept_id = cls.modifier_field_concept_id() + except NotImplementedError: + continue + if field_concept_id not in classes: + classes[field_concept_id] = cls + return classes + + +class _EpisodeEventTargetClassCache: + def __init__(self) -> None: + self._cache: dict[int, Type[Any]] | None = None + + def get(self) -> dict[int, Type[Any]]: + if self._cache is None: + self._cache = _modifier_target_classes_by_field_concept_id() + return self._cache + + def clear(self) -> None: + self._cache = None + + +_target_class_cache = _EpisodeEventTargetClassCache() + + +def clear_episode_event_target_class_cache() -> None: + """ + Clear cached episode_event field-concept target mappings. + + Honestly this isn't likely to be needed in normal operation, + but the unenforced polymorphism is hard to capture otherwise. + """ + _target_class_cache.clear() + + @cdm_table class Episode_Event(CDMTableBase, Base): __tablename__ = "episode_event" @@ -51,6 +94,18 @@ class Episode_EventView(Episode_Event, Episode_EventContext, DomainValidationMix "episode_event_field_concept_id": ExpectedDomain("Metadata"), } + @classmethod + def resolved_event_target_classes(cls) -> dict[int, Type[Any]]: + """ + Map episode_event field concepts to ORM classes that can receive them. + + Subclasses may override this to prefer domain-specific mapped views + over the base CDM views. The default map is built from registered + ``ModifierTargetMixin`` classes and keyed by the field concept id + itself, avoiding the fragile concept-name table parsing that the CDM + metadata labels happen to support today. + """ + return _target_class_cache.get() @property def event_table(self) -> str | None: @@ -58,21 +113,24 @@ def event_table(self) -> str | None: return self.event_field.concept_name.split(".", 1)[0] return None + @property + def resolved_event_class(self) -> Type[Any] | None: + return self.resolved_event_target_classes().get( + self.episode_event_field_concept_id + ) + @cached_property def resolved_event(self) -> Any | None: """ Resolve EVENT_ID to concrete OMOP row. Cached per-instance. """ - table_name = self.event_table session = so.object_session(self) - if session is None or table_name is None: + cls = self.resolved_event_class + if session is None or cls is None: return None - cls = cast(Type[Any] | None, get_model_by_tablename(table_name)) - if cls is not None: - return session.get(cls, self.event_id) - return None + return session.get(cls, self.event_id) def __repr__(self): target = self.resolved_event diff --git a/omop_alchemy/toolkit/__init__.py b/omop_alchemy/toolkit/__init__.py new file mode 100644 index 0000000..bc0bf56 --- /dev/null +++ b/omop_alchemy/toolkit/__init__.py @@ -0,0 +1,35 @@ +"""Clinical tooling built on top of the OMOP CDM models. + +``omop_alchemy.cdm`` gives you the CDM schema as SQLAlchemy models. The +toolkit is what you build with them: vocabulary resolution, patient +timelines, episode traversal, domain analytics, and outbound export. + +The toolkit is organised into four tiers: + +``core`` + Foundational services with no clinical-domain assumptions — concept + resolution, patient timelines, unit conversion. + +``episodes`` + Domain-neutral machinery for building episodes and traversing what + they contain. + +``analytics`` + Clinical-domain logic, one subpackage per domain — oncology, body + metrics, adverse events. + +``integrations`` + Outbound export to external data standards. + +Each tier depends only on the tiers above it in that list. ``core`` knows +nothing of episodes or clinical domains; ``integrations`` may use anything +but is used by nothing. + +Import from the area subpackages, which are the supported surface:: + + from omop_alchemy.toolkit.core.concepts import make_concept_resolver + from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode + +Individual module paths beneath an area are internal and may be +reorganised; the area subpackage always re-exports the public names. +""" diff --git a/omop_alchemy/toolkit/analytics/__init__.py b/omop_alchemy/toolkit/analytics/__init__.py new file mode 100644 index 0000000..8895021 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/__init__.py @@ -0,0 +1,25 @@ +"""Clinical logic for specific domains. + +Where ``core`` and ``episodes`` are deliberately domain-agnostic, the +subpackages here encode what a value *means* in a particular clinical +context: which concepts constitute a treatment, what counts as a +significant change, how severity is graded. + +``oncology`` + Cancer treatment and disease episodes, radiotherapy and systemic + therapy dosing, and the concept sets that define them. + +``body_metrics`` + Weight, height, and BMI as measurement series and trajectories. + +``adverse_events`` + Severity grading against published criteria. + +Each domain owns its own concept sets, kept beside the code that uses +them so that changing a domain's definition of a treatment or a threshold +touches one place. + +Domains may depend on ``core`` and ``episodes``, and on each other where +the clinical logic genuinely composes — oncology cachexia grading builds +on body-metric trajectories and adverse-event criteria. +""" diff --git a/omop_alchemy/toolkit/analytics/adverse_events/__init__.py b/omop_alchemy/toolkit/analytics/adverse_events/__init__.py new file mode 100644 index 0000000..6710c36 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/adverse_events/__init__.py @@ -0,0 +1,31 @@ +"""Grade clinical severity against published criteria. + +Computing that a patient lost 12% of their body weight is a measurement +question. Deciding what that means is a policy question, and the answer +depends on which published criteria you are applying. This module keeps +those criteria separate from the measurement code so that the grading +standard in use is always explicit. + +``ctcae_weight_loss_grade`` applies CTCAE-style percent-loss bins, using +percent weight change alone. CTCAE's intervention-based qualifiers are +not inferred from CDM data, so the grade reflects the physiological bins +only. + +``martin_weight_loss_grade`` applies the Martin et al. matrix, which +grades percent weight loss against BMI and so distinguishes the same +percentage loss in patients of different body composition. + +``critical_weight_loss_grade`` is the combined entry point: it uses +Martin grading where BMI is available and falls back to CTCAE-style +grading where it is not, provided percent change is evaluable:: + + from omop_alchemy.toolkit.analytics.adverse_events import ( + critical_weight_loss_grade, + ) + + grade = critical_weight_loss_grade(pct_change=-12.0, bmi=21.4) + +Grading functions take computed values, not ORM rows. Where the values +come from — which readings were admitted, over what window — is decided +in ``omop_alchemy.toolkit.analytics.body_metrics``. +""" diff --git a/omop_alchemy/toolkit/analytics/body_metrics/__init__.py b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py new file mode 100644 index 0000000..c869c85 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py @@ -0,0 +1,43 @@ +"""Weight, height, and BMI as measurement series and trajectories. + +Body measurements are recorded repeatedly, in inconsistent units, and +attributed to a patient rather than to any particular episode. Turning +them into something a clinical calculation can use means resolving which +readings apply, normalising them, and computing change over time. + +**Resolving readings.** ``resolve_measurement_series`` collects the +measurements attributable to an episode, honouring explicit +``Episode_Event`` links first and then admitting same-person readings +within the episode window. Height behaves differently: an adult height +recorded outside the window is still valid, so +``resolve_person_measurement_series`` collects all same-person readings +for concepts that are effectively invariant. + +Each resolved row becomes a ``MeasurementReading`` — value, date, unit, +and the ``ReadingSource`` recording how it was admitted. + +**Normalising.** ``normalize_weight_readings`` and +``normalize_height_readings`` convert a series to kilograms and +centimetres, dropping readings whose units cannot be interpreted rather +than assuming a default. + +**Trajectories.** ``WeightTrajectoryMixin`` gives an episode view +normalised weight and height, BMI, and windowed weight change:: + + class MyEpisode(WeightTrajectoryMixin, EpisodeView): + ... + + episode.bmi + episode.weight_change(days=180) + +``WeightChange`` reports ``pct_change`` as ``None`` whenever the change is +not evaluable — too few readings, or an unusable unit — so callers cannot +mistake an unknown for a zero. + +The concepts defining which measurements count as body size, and their +expected units, are declared in this package and overridable per +deployment via ``BodyMetricRules``. + +Severity grading of a weight change is a separate concern and lives in +``omop_alchemy.toolkit.analytics.adverse_events``. +""" diff --git a/omop_alchemy/toolkit/analytics/oncology/__init__.py b/omop_alchemy/toolkit/analytics/oncology/__init__.py new file mode 100644 index 0000000..ea01f5b --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/__init__.py @@ -0,0 +1,44 @@ +"""Cancer treatment and disease episodes, and the dosing they carry. + +Oncology care is structured: a disease episode spans a cancer course, a +treatment regimen sits beneath it, and cycles sit beneath the regimen. +This package provides that structure as navigable objects and answers the +questions that follow from it. + +``OncologyEpisode`` is the main entry point. It classifies itself from +its episode concepts — whether it is a treatment cycle, and which +``OncologyModality`` it represents (systemic therapy, radiotherapy, +surgery, or diagnostic staging) — and exposes the facts it contains:: + + from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode + + episode = session.get(OncologyEpisode, episode_id) + episode.structural_modality + episode.is_treatment_cycle + +Episode traversal resolves to oncology-aware fact classes. +``OncologyDrugExposure`` and ``OncologyProcedure`` extend their CDM +counterparts with domain questions such as whether a row represents +systemic therapy or radiotherapy, so traversal from an episode yields +objects that can answer them. Non-oncology targets resolve to the base +CDM classes as usual. + +**Dosing.** Radiotherapy and systemic therapy are dosed in +fundamentally different terms and are summarised separately. RT +summaries group by treatment site; systemic therapy summaries group by +drug or ingredient. Both report whether the dose is evaluable, because +source units and quantities are frequently not comparable across agents +or regimens — a summary that cannot be interpreted as a dose says so +rather than presenting a total that looks authoritative. + +**Concept sets.** Which concepts constitute systemic therapy, +radiotherapy, cancer-indicating surgery, diagnostic staging, and each +episode type are declared in this package and resolved against the +vocabulary tables on first use, then cached. ``clear_concept_set_cache`` +discards that cache when the vocabulary changes underneath a long-running +session. + +Governed default concept sets come from ``omop-semantics``, an optional +dependency. Install ``omop-alchemy[semantics]`` to use them; supply your +own concept sets otherwise. +""" diff --git a/omop_alchemy/toolkit/core/__init__.py b/omop_alchemy/toolkit/core/__init__.py new file mode 100644 index 0000000..2a94bbe --- /dev/null +++ b/omop_alchemy/toolkit/core/__init__.py @@ -0,0 +1,22 @@ +"""Foundational services shared across every clinical domain. + +Core holds the parts of the toolkit that carry no assumptions about what +kind of clinical question you are asking. A concept resolver behaves the +same whether it is mapping tumour morphology or procedures; a +patient timeline is the same object whatever populates it. + +``concepts`` + Map free text and source codes to OMOP concept IDs, and hold the + normalisation rules that make those mappings reproducible. + +``timeline`` + Project heterogeneous clinical rows into a single ordered sequence of + events for one person. + +``units`` + Convert measurement values to canonical units. + +Nothing in core imports from ``episodes``, ``analytics``, or +``integrations``. Domain-specific concept sets, thresholds, and grading +rules belong with their domain under ``analytics``, not here. +""" diff --git a/omop_alchemy/toolkit/core/_semantics.py b/omop_alchemy/toolkit/core/_semantics.py new file mode 100644 index 0000000..276085b --- /dev/null +++ b/omop_alchemy/toolkit/core/_semantics.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + + +def default_semantics_runtime() -> Any: + """ + Return the bundled omop-semantics runtime, or fail with install guidance. + + omop-semantics is an optional dependency of omop-alchemy. Handler modules + that use governed default concept sets should call this lazily when those + defaults are actually needed, so importing core CDM models does not require + the semantics package. + """ + try: + from omop_semantics.runtime.default_valuesets import runtime + except ImportError as exc: + raise ImportError( + "This handler requires omop-semantics. Install omop-alchemy with " + "the 'semantics' extra, for example: omop-alchemy[semantics]." + ) from exc + return runtime diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py new file mode 100644 index 0000000..79deb04 --- /dev/null +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -0,0 +1,69 @@ +"""Resolve free-text terms and source codes to OMOP concept IDs. + +Source data rarely arrives with concept IDs attached. This module turns a +declarative description of *which* concepts are eligible into a runtime +resolver that maps incoming text to those concepts, applying the same +normalisation on both sides so that matching is predictable. + +Three pieces make up the workflow: + +``LookupSpec`` + Declares which concepts belong in a lookup — by vocabulary, domain, + concept class, or explicit ancestry — and which text fields are + indexed. + +``LookupIndex`` + The materialised table of normalised text keys to concept IDs that a + spec produces against the vocabulary tables. + +``ConceptResolver`` + Wraps an index and resolves terms at runtime, applying the same + normalisation used to build the keys. + +``make_concept_resolver`` bundles all three for the common case:: + + from omop_alchemy.toolkit.core.concepts import make_concept_resolver + + resolver = make_concept_resolver(session, spec) + concept_id = resolver.resolve("Adenocarcinoma of lung") + +Normalisation is composable. ``compose_normalizers`` chains individual +rules — ``normalize_default`` for whitespace and casing, ``strip_uicc`` +and ``make_stage`` for staging text, ``site_to_NOS`` for site +generalisation — so that a resolver's matching behaviour is stated +explicitly rather than implied. + +Building an index queries the vocabulary tables, so resolvers are worth +reusing. ``ConceptResolverRegistry`` constructs each resolver on first +access and caches it for the registry's lifetime. +""" + +from .lookup import ( + ConceptResolver, + LookupIndex, + LookupSpec, + OMOPConceptSource, + make_concept_resolver, +) +from .normalizers import ( + compose_normalizers, + make_stage, + normalize_default, + site_to_NOS, + strip_uicc, +) +from .registry import ConceptResolverRegistry + +__all__ = [ + "ConceptResolver", + "ConceptResolverRegistry", + "LookupIndex", + "LookupSpec", + "OMOPConceptSource", + "compose_normalizers", + "make_concept_resolver", + "make_stage", + "normalize_default", + "site_to_NOS", + "strip_uicc", +] diff --git a/omop_alchemy/cdm/handlers/vocabs_and_mappers/vocab_handlers.py b/omop_alchemy/toolkit/core/concepts/lookup.py similarity index 98% rename from omop_alchemy/cdm/handlers/vocabs_and_mappers/vocab_handlers.py rename to omop_alchemy/toolkit/core/concepts/lookup.py index 79e3d0f..61a5923 100644 --- a/omop_alchemy/cdm/handlers/vocabs_and_mappers/vocab_handlers.py +++ b/omop_alchemy/toolkit/core/concepts/lookup.py @@ -3,9 +3,9 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from .concept_normalisers import normalize_default -from ...model import ConceptRow -from ...model.vocabulary import Concept, Concept_Synonym, Concept_Ancestor +from .normalizers import normalize_default +from omop_alchemy.cdm.model import ConceptRow +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Synonym, Concept_Ancestor """ Class definitions for vocabulary handling and mapping. diff --git a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_normalisers.py b/omop_alchemy/toolkit/core/concepts/normalizers.py similarity index 96% rename from omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_normalisers.py rename to omop_alchemy/toolkit/core/concepts/normalizers.py index 84abaaa..19a81ed 100644 --- a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_normalisers.py +++ b/omop_alchemy/toolkit/core/concepts/normalizers.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .vocab_handlers import Normaliser + from .lookup import Normaliser def compose_normalizers(*fns: "Normaliser") -> "Normaliser": def _inner(s: str) -> str: diff --git a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_registry.py b/omop_alchemy/toolkit/core/concepts/registry.py similarity index 97% rename from omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_registry.py rename to omop_alchemy/toolkit/core/concepts/registry.py index e1ca8ae..41c8632 100644 --- a/omop_alchemy/cdm/handlers/vocabs_and_mappers/concept_registry.py +++ b/omop_alchemy/toolkit/core/concepts/registry.py @@ -2,7 +2,7 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from .vocab_handlers import ConceptResolver +from .lookup import ConceptResolver class ConceptResolverRegistry: """ diff --git a/omop_alchemy/toolkit/core/timeline/__init__.py b/omop_alchemy/toolkit/core/timeline/__init__.py new file mode 100644 index 0000000..0a230f3 --- /dev/null +++ b/omop_alchemy/toolkit/core/timeline/__init__.py @@ -0,0 +1,45 @@ +"""Project a person's clinical records into one ordered event sequence. + +Conditions, measurements, and drug exposures live in separate CDM tables +with differently named date and value columns. Answering "what happened +to this patient, in order" means reconciling them. ``Person_Timeline`` +does that reconciliation and presents the result as a single list of +events sorted by time. + +Each event exposes a canonical time and value regardless of which table it +came from, so callers can iterate a patient's history without special- +casing per table:: + + from omop_alchemy.toolkit.core.timeline import Person_Timeline + + for event in person.timeline: + print(event.time, event.concept_id, event.value) + +The timeline is a flat chronological view. It carries no notion of which +events group together into a treatment course or disease phase — that +grouping is what ``omop_alchemy.toolkit.episodes`` provides. +""" + +from .event_timeline import ( + ClinicalEvent, + ClinicalEventProtocol, + Condition_Event, + Drug_Exposure_Event, + EventMapping, + EventTime, + EventValue, + Measurement_Event, + Person_Timeline, +) + +__all__ = [ + "ClinicalEvent", + "ClinicalEventProtocol", + "Condition_Event", + "Drug_Exposure_Event", + "EventMapping", + "EventTime", + "EventValue", + "Measurement_Event", + "Person_Timeline", +] diff --git a/omop_alchemy/cdm/handlers/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py similarity index 98% rename from omop_alchemy/cdm/handlers/timeline/event_timeline.py rename to omop_alchemy/toolkit/core/timeline/event_timeline.py index 226093f..16fce08 100644 --- a/omop_alchemy/cdm/handlers/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -1,5 +1,5 @@ -from ...model.clinical import Measurement, Person, Condition_Occurrence, Drug_Exposure +from omop_alchemy.cdm.model.clinical import Measurement, Person, Condition_Occurrence, Drug_Exposure from sqlalchemy.orm import object_session from sqlalchemy import select from datetime import datetime, time, date diff --git a/omop_alchemy/toolkit/core/units/__init__.py b/omop_alchemy/toolkit/core/units/__init__.py new file mode 100644 index 0000000..a5d8cc0 --- /dev/null +++ b/omop_alchemy/toolkit/core/units/__init__.py @@ -0,0 +1,36 @@ +"""Convert measurement values to canonical units. + +Measurement rows carry whatever unit the source system recorded. Before +values can be compared, trended, or fed into a calculation they need to +agree on a unit, and that conversion must be explicit about what it will +and will not accept. + +``BodyUnitConversionRules`` converts anthropometric measurements to +kilograms and centimetres, driven by the unit concept on each row rather +than by guesswork. ``default_body_unit_conversion_rules`` supplies the +standard rule set; ``INCH_TO_CM`` and ``LB_US_TO_KG`` are the underlying +factors where a caller needs them directly. + +A reading whose unit concept is unrecognised is not converted and not +silently passed through — callers are told the value could not be +normalised, so an unconvertible unit never reaches a calculation +disguised as a valid one. +""" + +from .body_units import ( + INCH_TO_CM, + LB_US_TO_KG, + BodySizeUnitConcepts, + BodyUnitConversionRules, + default_body_size_unit_concepts, + default_body_unit_conversion_rules, +) + +__all__ = [ + "INCH_TO_CM", + "LB_US_TO_KG", + "BodySizeUnitConcepts", + "BodyUnitConversionRules", + "default_body_size_unit_concepts", + "default_body_unit_conversion_rules", +] diff --git a/omop_alchemy/toolkit/core/units/body_units.py b/omop_alchemy/toolkit/core/units/body_units.py new file mode 100644 index 0000000..d8c561e --- /dev/null +++ b/omop_alchemy/toolkit/core/units/body_units.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from typing import Optional + +from omop_alchemy.toolkit.core._semantics import default_semantics_runtime + +LB_US_TO_KG = 0.45359237 +INCH_TO_CM = 2.54 + + +@dataclass(frozen=True) +class BodySizeUnitConcepts: + """ + The unit concepts anthropometric conversion recognises. + + Kilograms, pounds, centimetres, inches, and square metres mean the same + thing in every clinical domain, so the mapping from concept ID to unit + lives here rather than with any one domain's measurement logic. + """ + + kg: int + lb: int + cm: int + inch: int + m2: int + + +def default_body_size_unit_concepts() -> BodySizeUnitConcepts: + runtime = default_semantics_runtime() + units = runtime.measurements_numeric.body_size_units + return BodySizeUnitConcepts( + kg=units.kg, + lb=units.lb, + cm=units.cm, + inch=units.inch, + m2=units.m2, + ) + + +@dataclass(frozen=True) +class BodyUnitConversionRules: + """ + Unit normalisation for anthropometric measurements. + + Conversion is driven by the unit concept recorded on each row. A value + whose unit concept is not in ``units`` converts to None rather than being + passed through, so a reading in an unrecognised unit cannot reach a + calculation disguised as a valid one. + + Deployments that record body size against non-standard unit concepts can + supply their own ``units`` instead of the governed defaults. + """ + + units: BodySizeUnitConcepts + lb_to_kg: float = LB_US_TO_KG + inch_to_cm: float = INCH_TO_CM + + @classmethod + def default(cls) -> "BodyUnitConversionRules": + return default_body_unit_conversion_rules() + + @property + def weight_unit_conversion_to_kg(self) -> dict[int, float]: + return { + self.units.kg: 1.0, + self.units.lb: self.lb_to_kg, + } + + @property + def height_unit_conversion_to_cm(self) -> dict[int, float]: + return { + self.units.cm: 1.0, + self.units.inch: self.inch_to_cm, + } + + def normalize_weight_kg( + self, + value: Optional[float], + unit_concept_id: Optional[int], + ) -> Optional[float]: + if value is None or unit_concept_id is None: + return None + factor = self.weight_unit_conversion_to_kg.get(unit_concept_id) + return value * factor if factor is not None else None + + def normalize_height_cm( + self, + value: Optional[float], + unit_concept_id: Optional[int], + ) -> Optional[float]: + if value is None or unit_concept_id is None: + return None + factor = self.height_unit_conversion_to_cm.get(unit_concept_id) + return value * factor if factor is not None else None + + +@lru_cache(maxsize=1) +def default_body_unit_conversion_rules() -> BodyUnitConversionRules: + return BodyUnitConversionRules(units=default_body_size_unit_concepts()) diff --git a/omop_alchemy/toolkit/episodes/__init__.py b/omop_alchemy/toolkit/episodes/__init__.py new file mode 100644 index 0000000..cd601fd --- /dev/null +++ b/omop_alchemy/toolkit/episodes/__init__.py @@ -0,0 +1,24 @@ +"""Build episodes and traverse what belongs to them. + +An OMOP ``Episode`` groups clinical events into a clinically meaningful +unit — a treatment regimen, a cycle within it, a disease course. Two +questions follow from that, and this tier answers both: + +``derivation`` + How episodes are constructed and related to one another — building + episode queries, resolving parent/child hierarchy, and establishing + the windows over which events are attributed. + +``handling`` + What is inside an episode once it exists — retrieving the linked + drug exposures, procedures, and measurements, and summarising them. + +Everything here is domain-neutral. A drug episode behaves the same +whether the drug is a cytotoxic agent or an antibiotic, so the mixins and +resolvers in this tier take concept filters and grouping keys as +parameters rather than assuming a clinical specialty. + +Domain-specific episode classes compose these pieces with their own +concept sets and rules, and live with their domain — for example +``OncologyEpisode`` in ``omop_alchemy.toolkit.analytics.oncology``. +""" diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py new file mode 100644 index 0000000..8b2c36b --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -0,0 +1,21 @@ +"""Construct episodes and resolve the relationships between them. + +Episodes in the CDM are rows that reference each other through +``episode_parent_id`` and reach clinical facts through ``Episode_Event``. +Turning that into something queryable — a regimen with its cycles, a +diagnosis with the treatment that followed — is what this module does. + +It provides queries that select episodes by concept, that join parent and +child episodes into a single result so a hierarchy can be read in one +pass, and that establish the date windows relating one episode to +another. + +These queries are written against the raw ``Episode`` and +``Episode_Event`` tables and depend on no materialised views, so they run +against any conformant CDM instance. Query construction against +materialised-view subclasses lives in ``omop-constructs``. + +Callers supply the episode concept IDs they care about. Derivation +determines structure; it does not decide which concepts constitute a +regimen or a disease course for a given specialty. +""" diff --git a/omop_alchemy/toolkit/episodes/handling/__init__.py b/omop_alchemy/toolkit/episodes/handling/__init__.py new file mode 100644 index 0000000..9def358 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/__init__.py @@ -0,0 +1,52 @@ +"""Retrieve and summarise the clinical facts an episode contains. + +Once an episode exists, the recurring question is what belongs to it. +Some facts are linked explicitly through ``Episode_Event``; others fall +inside the episode's dates but were never linked. This module resolves +both, and is explicit about which rule admitted each row. + +Explicit ``Episode_Event`` links are always honoured. A bounded +date-based fallback can be enabled per caller for sources that do not +populate episode links reliably, and ``episode_attachment_window`` defines +the window used. Open-ended episodes are handled without letting the +window run unbounded. + +``DrugEpisodeMixin`` adds linked-drug retrieval to any episode view. +Subclasses supply the concept filter and grouping key:: + + class MyEpisode(DrugEpisodeMixin, EpisodeView): + _drug_concept_ids = my_concept_ids + + episode.drug_exposures # resolved rows + episode.drug_exposure_summaries_by() # grouped summaries + +Summaries group exposures by any key the caller chooses — drug concept, +ingredient, or regimen member — via ``DrugExposureSummary`` and +``summarize_drug_exposures_by``. + +Dose quantities are frequently not comparable across agents, because +source units and quantities arrive unnormalised. ``DoseEvaluability`` +carries that judgement alongside the number, so a summary that cannot be +interpreted as a dose says so rather than presenting a misleading total. + +An explicit ``Episode_Event`` link can still fail to resolve — a miscoded +field concept, a target class not yet registered, a dangling reference. +``ResolvedEpisodeEvent`` reports which of those applied instead of +silently returning ``None``; mix ``ResolvedEpisodeEventMixin`` into an +episode view to reach it through ordinary ``episode.episode_events`` +traversal. +""" + +from .resolved_event import ( + EpisodeEventResolutionDiagnostic, + ResolutionDiagnosticKind, + ResolvedEpisodeEvent, + ResolvedEpisodeEventMixin, +) + +__all__ = [ + "EpisodeEventResolutionDiagnostic", + "ResolutionDiagnosticKind", + "ResolvedEpisodeEvent", + "ResolvedEpisodeEventMixin", +] diff --git a/omop_alchemy/toolkit/episodes/handling/resolved_event.py b/omop_alchemy/toolkit/episodes/handling/resolved_event.py new file mode 100644 index 0000000..4a9d0bf --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/resolved_event.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, cast + +import sqlalchemy.orm as so +from sqlalchemy.ext.declarative import declared_attr + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model.structural.episode_event import Episode_EventView + +ResolutionDiagnosticKind = Literal[ + "unrecognized_field_concept", + "unmapped_field_concept", + "dangling_event", +] + + +@dataclass(frozen=True) +class EpisodeEventResolutionDiagnostic: + """ + Advisory detail for an episode_event row whose target cannot be resolved. + + Resolution remains best-effort: unresolved rows still return ``None`` from + ``resolved_event``. These diagnostics give maintenance and QA code enough + information to distinguish miscoded field concepts from harmless ORM + coverage gaps and genuine dangling event references. + """ + + kind: ResolutionDiagnosticKind + episode_id: int + event_id: int + episode_event_field_concept_id: int + message: str + + +def _known_modifier_field_concept_ids() -> set[int]: + return { + value + for name, value in vars(ModifierFieldConcepts).items() + if name.isupper() and isinstance(value, int) + } + + +class ResolvedEpisodeEvent(Episode_EventView): + """ + Episode-event view that explains why a link did not resolve. + + ``Episode_EventView.resolved_event`` already resolves best-effort and + returns ``None`` on failure. ``event_resolution_diagnostics`` names which + of three reasons applied: the field concept is not a recognised + ``ModifierFieldConcepts`` value, it is recognised but no registered ORM + class declares it, or the target row itself is missing. + + Query this class directly for diagnostics on a known episode_event, or + mix ``ResolvedEpisodeEventMixin`` into an episode view to reach it + through ordinary ``episode.episode_events`` traversal. + """ + + @classmethod + def recognized_field_concept_ids(cls) -> set[int]: + return _known_modifier_field_concept_ids() + + @property + def event_resolution_diagnostics(self) -> list[EpisodeEventResolutionDiagnostic]: + session = so.object_session(self) + field_concept_id = self.episode_event_field_concept_id + + if field_concept_id not in self.recognized_field_concept_ids(): + return [ + EpisodeEventResolutionDiagnostic( + kind="unrecognized_field_concept", + episode_id=self.episode_id, + event_id=self.event_id, + episode_event_field_concept_id=field_concept_id, + message=( + "episode_event_field_concept_id is not a known " + "ModifierFieldConcepts value" + ), + ) + ] + + target_cls = self.resolved_event_class + if target_cls is None: + return [ + EpisodeEventResolutionDiagnostic( + kind="unmapped_field_concept", + episode_id=self.episode_id, + event_id=self.event_id, + episode_event_field_concept_id=field_concept_id, + message=( + "episode_event_field_concept_id is recognized, but no " + "registered ORM target class declares it" + ), + ) + ] + + if session is None: + return [] + + if session.get(target_cls, self.event_id) is None: + return [ + EpisodeEventResolutionDiagnostic( + kind="dangling_event", + episode_id=self.episode_id, + event_id=self.event_id, + episode_event_field_concept_id=field_concept_id, + message=( + f"episode_event points to {target_cls.__name__}#{self.event_id}, " + "but no matching row exists" + ), + ) + ] + + return [] + + +class ResolvedEpisodeEventMixin: + """ + Relationship override so episode views can traverse to resolution diagnostics. + + Mix into an ``EpisodeContext``-derived view to have ``episode_events`` + load ``ResolvedEpisodeEvent`` rows instead of the bare + ``Episode_EventView``, making + ``episode.episode_events[0].event_resolution_diagnostics`` reachable + through ordinary traversal rather than a direct query. + """ + + @declared_attr + @classmethod + def episode_events(cls) -> so.Mapped[list["ResolvedEpisodeEvent"]]: + owner_episode_id = cast(Any, cls).__table__.c.episode_id + event_episode_id = ResolvedEpisodeEvent.__table__.c.episode_id + return so.relationship( + "ResolvedEpisodeEvent", + primaryjoin=lambda: owner_episode_id == event_episode_id, + viewonly=True, + lazy="selectin", + ) diff --git a/omop_alchemy/toolkit/integrations/__init__.py b/omop_alchemy/toolkit/integrations/__init__.py new file mode 100644 index 0000000..03c8ead --- /dev/null +++ b/omop_alchemy/toolkit/integrations/__init__.py @@ -0,0 +1,15 @@ +"""Export OMOP CDM data to external standards. + +Integrations convert a CDM database into the format another ecosystem +expects. They sit at the outer edge of the toolkit: an integration may +use anything in ``core``, ``episodes``, or ``analytics``, and nothing in +those tiers depends on an integration. Adding or changing an export +format therefore cannot affect the clinical logic beneath it. + +``meds_standard`` + Export to the Medical Event Data Standard. + +Integrations bring their own heavyweight dependencies and each is gated +behind an optional extra, so installing omop-alchemy does not pull in +formats you are not exporting to. +""" diff --git a/omop_alchemy/toolkit/integrations/meds_standard/__init__.py b/omop_alchemy/toolkit/integrations/meds_standard/__init__.py new file mode 100644 index 0000000..b173ff8 --- /dev/null +++ b/omop_alchemy/toolkit/integrations/meds_standard/__init__.py @@ -0,0 +1,44 @@ +"""Export an OMOP CDM database to a MEDS-compliant dataset. + +The `Medical Event Data Standard`_ represents a patient's record as a flat +sequence of coded events. This module converts a CDM instance into that +form, serialising to sharded Parquet with PyArrow and emitting the code +metadata that makes the export self-describing. + +Requires the optional extras:: + + pip install omop_alchemy[meds] + +Whole-dataset export:: + + from omop_alchemy.toolkit.integrations.meds_standard import MEDSWriter + + with Session() as session: + result = MEDSWriter(session, "/path/to/output").write() + + print(f"Exported {result.subjects_exported} subjects, " + f"{result.events_emitted} events across {result.shards_written} shards.") + +``WriteResult`` also reports ``drop_counts`` per source table. Rows that +cannot be represented as MEDS events are dropped and counted rather than +silently omitted, so an export can be reconciled against its source. + +Single-patient export, for inspection or interactive work:: + + from omop_alchemy.toolkit.integrations.meds_standard import ( + Person_MEDS, + build_concept_id_map, + ) + + code_map, _ = build_concept_id_map(session) + person = session.get(Person_MEDS, 12345) + table = person.to_meds_table(code_map) # pa.Table, validates DataSchema + +Conditions, drug exposures, measurements, observations, procedures, and +device exposures each have an event class that maps their CDM columns onto +the MEDS event schema. Source concepts with no standard mapping are +assigned custom codes numbered from ``CUSTOM_CONCEPT_ID_START``, keeping +them distinguishable from OMOP concept IDs in the exported data. + +.. _Medical Event Data Standard: https://github.com/Medical-Event-Data-Standard/meds +""" diff --git a/pyproject.toml b/pyproject.toml index 9dc5ad5..389a3d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ postgres = [ "psycopg[binary]>=3.2", ] +semantics = [ + "omop-semantics>=0.5.0", +] + dev = [ "oa-configurator[dev,postgres]==0.1.2", "ipython>=8.0", @@ -52,6 +56,7 @@ dev = [ "pytest-cov>=4.0", "ty==0.0.61", "ruff>=0.4", + "import-linter>=2.0", "mkdocs-material>=9.7.1", "mkdocstrings-python>=2.0.1", "mkdocs>=1.6.1", @@ -85,4 +90,4 @@ packages = ["omop_alchemy"] [tool.uv] cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }] -[tool.pytest.ini_options] +[tool.pytest.ini_options] \ No newline at end of file diff --git a/tests/test_concept_validation.py b/tests/test_concept_validation.py new file mode 100644 index 0000000..49af139 --- /dev/null +++ b/tests/test_concept_validation.py @@ -0,0 +1,118 @@ +"""Tests for ConceptValidationMixin._non_standard_concepts_for_column, the +referenced-concept standard-concept check. + +Regression coverage for the §3.1 behaviour change: ConceptValidationMixin now +delegates to Concept.is_standard_expr() instead of its own hand-rolled check, +closing two bugs (blank/whitespace flags and non-canonical values such as 'X' +previously passed as valid) without reopening the NULL three-valued-logic trap +that motivated is_not(True) over sa.not_(...). + +_non_standard_concepts_for_column takes its table/column as plain parameters +and does not use ``cls``, so it is exercised directly on +``ConceptValidationMixin`` against ``Condition_Occurrence``'s real mapped +table, without needing the mixin to actually be applied to a mapped class. +""" + +from datetime import date + +import pytest +import sqlalchemy as sa + +from omop_alchemy.cdm.base import ConceptValidationMixin +from omop_alchemy.cdm.model.clinical import Condition_Occurrence +from omop_alchemy.cdm.model.vocabulary import Concept + + +def _type_concept_id(session) -> int: + return session.scalar( + sa.select(Concept.concept_id).where(Concept.domain_id == "Type Concept").limit(1) + ) + + +def _seed_dirty_concept(session, concept_id, standard_concept): + session.add( + Concept( + concept_id=concept_id, + concept_name=f"fixture concept {concept_id}", + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept=standard_concept, + concept_code=f"fixture-{concept_id}", + valid_start_date=date(1970, 1, 1), + valid_end_date=date(2099, 12, 31), + ) + ) + session.flush() + + +def _seed_condition(session, *, condition_occurrence_id, condition_concept_id, type_concept_id): + session.add( + Condition_Occurrence( + condition_occurrence_id=condition_occurrence_id, + person_id=1, + condition_concept_id=condition_concept_id, + condition_start_date=date(2020, 1, 1), + condition_type_concept_id=type_concept_id, + condition_source_value="concept-validation-fixture", + ) + ) + session.flush() + + +def _violations_for(session, condition_occurrence_id) -> set[int]: + table = Condition_Occurrence.__table__ + col = table.c.condition_concept_id + stmt = ConceptValidationMixin._non_standard_concepts_for_column( + table=table, col=col + ).where(table.c.condition_occurrence_id == condition_occurrence_id) + return {int(cid) for (cid,) in session.execute(stmt)} + + +@pytest.mark.parametrize( + "case_id, standard_concept, expect_flagged", + [ + (1, "S", False), + (2, "C", False), + (3, None, True), + (4, "", True), + (5, " ", True), + (6, "X", True), + ], +) +def test_non_standard_concepts_for_column_matches_is_standard_expr( + session, case_id, standard_concept, expect_flagged +): + """Pins the §3.1 behaviour change: blank, whitespace, and 'X' are now + correctly flagged as non-standard (previously bugs let them through), + while 'S'/'C' remain accepted. A NULL standard_concept must still be + flagged — the sa.not_(...) form this replaced would have silently missed + it, since NOT (x IN (...)) is NULL, not TRUE, when x is NULL.""" + concept_id = 900000 + case_id + condition_occurrence_id = 800000 + case_id + _seed_dirty_concept(session, concept_id, standard_concept) + _seed_condition( + session, + condition_occurrence_id=condition_occurrence_id, + condition_concept_id=concept_id, + type_concept_id=_type_concept_id(session), + ) + + violations = _violations_for(session, condition_occurrence_id) + assert (concept_id in violations) is expect_flagged + + +def test_non_standard_concepts_for_column_flags_missing_concept_row(session): + """A dangling *_concept_id with no matching concept row at all is a + violation, not silently ignored by the outer join.""" + missing_concept_id = 900099 + condition_occurrence_id = 800099 + _seed_condition( + session, + condition_occurrence_id=condition_occurrence_id, + condition_concept_id=missing_concept_id, + type_concept_id=_type_concept_id(session), + ) + + violations = _violations_for(session, condition_occurrence_id) + assert missing_concept_id in violations diff --git a/tests/test_episodes_basic.py b/tests/test_episodes_basic.py index b339ac9..669ceab 100644 --- a/tests/test_episodes_basic.py +++ b/tests/test_episodes_basic.py @@ -1,4 +1,11 @@ -from omop_alchemy.cdm.model.structural import EpisodeView, Episode_Event +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model.structural import ( + EpisodeView, + Episode_Event, + Episode_EventView, + clear_episode_event_target_class_cache, +) +from omop_alchemy.toolkit.episodes.handling import ResolvedEpisodeEvent import sqlalchemy as sa def test_episode_view_expected_domains(): @@ -63,6 +70,106 @@ def test_episode_event_resolves_target(session): assert getattr(target, col) == ee.event_id +def test_episode_event_resolution_has_no_diagnostics_for_valid_target(session): + """Valid episode_event links resolve without advisory issues.""" + ee = ( + session.query(EpisodeView) + .filter(EpisodeView.episode_events.any()) + .first() + .episode_events[0] + ) + resolved = session.get( + ResolvedEpisodeEvent, + (ee.episode_id, ee.event_id, ee.episode_event_field_concept_id), + ) + + assert resolved.resolved_event is not None + assert resolved.event_resolution_diagnostics == [] + + +def test_episode_event_resolution_reports_unrecognized_field_concept(session): + """A field concept outside ModifierFieldConcepts is surfaced separately.""" + ee = Episode_Event( + episode_id=101, + event_id=998, + episode_event_field_concept_id=201826, + ) + session.add(ee) + session.flush() + session.expire_all() + + loaded = session.get(ResolvedEpisodeEvent, (101, 998, 201826)) + diagnostics = loaded.event_resolution_diagnostics + + assert loaded.resolved_event is None + assert len(diagnostics) == 1 + assert diagnostics[0].kind == "unrecognized_field_concept" + + +def test_episode_event_resolution_reports_unmapped_known_field_concept( + session, + monkeypatch, +): + """Known field concepts can be valid data even before this ORM maps them.""" + ee = ( + session.query(EpisodeView) + .filter(EpisodeView.episode_events.any()) + .first() + .episode_events[0] + ) + resolved = session.get( + ResolvedEpisodeEvent, + (ee.episode_id, ee.event_id, ee.episode_event_field_concept_id), + ) + monkeypatch.setattr( + type(resolved), + "resolved_event_target_classes", + classmethod(lambda cls: {}), + ) + + diagnostics = resolved.event_resolution_diagnostics + + assert resolved.resolved_event is None + assert len(diagnostics) == 1 + assert diagnostics[0].kind == "unmapped_field_concept" + + +def test_episode_event_resolution_reports_dangling_target(session): + """Recognized field concepts with missing target rows are true dangling links.""" + ee = Episode_Event( + episode_id=101, + event_id=999, + episode_event_field_concept_id=ModifierFieldConcepts.CONDITION_OCCURRENCE, + ) + session.add(ee) + session.flush() + session.expire_all() + + loaded = session.get( + ResolvedEpisodeEvent, + (101, 999, ModifierFieldConcepts.CONDITION_OCCURRENCE), + ) + diagnostics = loaded.event_resolution_diagnostics + + assert loaded.resolved_event is None + assert len(diagnostics) == 1 + assert diagnostics[0].kind == "dangling_event" + + +def test_episode_event_target_class_cache_can_be_cleared(): + """The resolver map is memoized but explicitly invalidatable.""" + clear_episode_event_target_class_cache() + first = Episode_EventView.resolved_event_target_classes() + second = Episode_EventView.resolved_event_target_classes() + + assert first is second + + clear_episode_event_target_class_cache() + third = Episode_EventView.resolved_event_target_classes() + + assert third is not first + assert third == first + def test_episode_view_events_property(session): """Test episode view events property.""" @@ -99,6 +206,15 @@ def test_episode_parent_relationship(session): assert child.parent_episode.episode_id == child.episode_parent_id +def test_episode_children_relationship_preserves_episode_view(session): + """Child navigation returns the concrete episode view being queried.""" + parent = session.get(EpisodeView, 100) + + assert parent is not None + assert [child.episode_id for child in parent.children] == [101] + assert all(isinstance(child, EpisodeView) for child in parent.children) + + def test_episode_date_bounds(session): """Test episode date bounds.""" ep = session.query(EpisodeView).first() diff --git a/uv.lock b/uv.lock index 5272d8b..5d3f19b 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,15 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -31,6 +40,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -80,6 +117,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -89,6 +135,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-zstd" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/b5/5a873da082bd08acd6a497f7aae224e94a7c27fa8f24488089cc50a16c84/backports_zstd-1.6.0.tar.gz", hash = "sha256:80a7859ffe70bf239d7a2ce15293bdeb5b4280ff7dc326ffab312b0e254dbb24", size = 1000009, upload-time = "2026-06-14T10:50:58.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/bb/009af3a9532d4cc66d5385391c512210fae32ab2442605f26aca1d8d2957/backports_zstd-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0466b14723f3b7697669c00ee66fe16e30e25636b286b0a923fa86fa3d8a753c", size = 437407, upload-time = "2026-06-14T10:49:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/0c/76/f7c02efde81ebb9993586f9e435d2fd1191a6f806f640e4eeb8d004493ed/backports_zstd-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d146926e997d2d3de8212bdcbf4985344a2622ca3bec458d8908000a84fd883", size = 363519, upload-time = "2026-06-14T10:49:51.383Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/0cf66f12472fe3e082cc4134395a7e0b8746cfb30aabd74251ce8fafa9a7/backports_zstd-1.6.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:460fd6b3f338c659507ae36cfd6b58ac9942a2ff233c5cf574416dfec0451a84", size = 507756, upload-time = "2026-06-14T10:49:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/95/7ed25c90369360f96f8bfa961540845e063377c32a43b775201af66a588c/backports_zstd-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c2b1f4a640c51130caa92cef5bf72bd3c3dbbcfbf814c37403aa0601b1811b0", size = 477578, upload-time = "2026-06-14T10:49:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/e3/75/f16b1d3e33ca396525847c81d96e3de7bc74d2c6f9ca2ddee76b0c450697/backports_zstd-1.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:beb43e9885202c8d4f3762319ed4d5e98e197622afbff8439fbbdd81d08938b9", size = 583029, upload-time = "2026-06-14T10:49:55.132Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/a17b111b631e1c79a0e570881c1a266c661b936585afa395435a458b1991/backports_zstd-1.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fbb746522ebfc11155f1cd688e2c48ef3d74125e38b63eabdaab068a055c3e88", size = 641741, upload-time = "2026-06-14T10:49:56.42Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b2/d17b2722c636d64b4e77ddc68d8d0625719d39f94021be8719a218af4c0a/backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93", size = 495554, upload-time = "2026-06-14T10:49:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/2853e8b6c03f03795b6548ea61f82cc104d4f7ff2523a04bc69f46984663/backports_zstd-1.6.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f69365ee2b836939137de024a302395a1cb8654fb6dc5ffef6381105259c8f87", size = 570027, upload-time = "2026-06-14T10:49:59.003Z" }, + { url = "https://files.pythonhosted.org/packages/18/aa/83f37b81f3b8c6ea035bf260ec374648bd59372894c02323dc9de3cbdf77/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66cf8038893c7708ec345ffb3ac63c775d10f430f323ac2f0334fdb6a397c57c", size = 483594, upload-time = "2026-06-14T10:50:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6a/d77f8cd2ff642d3b3652c1ccab5b6583114dbf10f8cb0143531357c83998/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e514c71ca72f3b56bd8fbda1a6a5b7d1100a2764b42a3c74a38841f25f9b00ab", size = 511206, upload-time = "2026-06-14T10:50:01.86Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/99a60fe4d1aac8053769d2463271d5df37a7c11c387072fdbb0b16aed7f7/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7741e44f7938ec94f9a52678c8d19b7bc548522ffdc39c9e4481af8db545fa9a", size = 587416, upload-time = "2026-06-14T10:50:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1e/a9c003fe4d14bd4bf671598d4c7dcc1cef51e3513d9d7111ba1d07b6f07b/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97e8a9674652496c7612b528085dd5a296c052a2edc466ca1bfb7b0b27820413", size = 567615, upload-time = "2026-06-14T10:50:04.524Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b9/955bd604f692c550c7cb66d00bd7691ead5c86df8ebd23d7254eeaa90789/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:23a793f2fed4dbf0517319759a2cded0b0dd8e8d3797fe30badd5693e320c175", size = 632269, upload-time = "2026-06-14T10:50:05.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/9f61f612f8a4193484c78a1f26db82a50141234189885113ef0085a8a961/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b951113113ed4b8d173418a4f155c14b739dace626b3fa3f82be1831958d39e4", size = 500066, upload-time = "2026-06-14T10:50:07.446Z" }, + { url = "https://files.pythonhosted.org/packages/81/a3/19fb8c48d94139481c5ccaf2fb54c31b543fa635fd7bd7399aadd15752ac/backports_zstd-1.6.0-cp312-cp312-win32.whl", hash = "sha256:6430b34a2ae6fcc604672f4f913102563473d9a015bdca1ce8c95041cc1f2677", size = 291825, upload-time = "2026-06-14T10:50:08.762Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/40ba081c6c71f0f22c64d3d54b912ad75a4e6812caa1397cbb15b5693b12/backports_zstd-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:08793876172551a930ce4d65c712cd516184d1a97070d4a1193e05bf0cf7040d", size = 329201, upload-time = "2026-06-14T10:50:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/f7116dd2edc6f960545f0d8616939eae3a20031b3b6669697d4f9fd83b2e/backports_zstd-1.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:03b7c59c71f7a597e2bcb3f8368371e9a660a1bdf1c37afc1f1ad1496a013c19", size = 291901, upload-time = "2026-06-14T10:50:11.198Z" }, + { url = "https://files.pythonhosted.org/packages/38/06/c430537d59c55d49bcd15ecf4b1aa965453219caad810a4f2b484816f4be/backports_zstd-1.6.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:2ace939e4d620e119423606f2d3d7115f8707733bf57f279ad9a9383f875986f", size = 400327, upload-time = "2026-06-14T10:50:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/36/48/2f8323bb0e3ebba88b54877a2979afeb83983fb2ca572f09ad61aae2d3a0/backports_zstd-1.6.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:4c68a9ed2df0cca51d774c521e68a34d2e3d9ebfc687ef8096adfd4f345b551d", size = 454276, upload-time = "2026-06-14T10:50:13.667Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/87a665244a65f5b87a06b848c29a8cce07e91d59c5988ee2a32c0293a21c/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:30576f49b82328ec8af16c11100efe52ca88526f71bbe100ef6b4e707dc13bf2", size = 357457, upload-time = "2026-06-14T10:50:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/854d4a47bb8b7a48bfb2ed381c7b03a70efb4fc49f0e4a1509b38a2e1727/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b4bddfcfb6679215d6f4dc5f79a1f9301af339480d70527a14b57a1f2e6b6cbf", size = 366139, upload-time = "2026-06-14T10:50:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/c3af43eb8df6f2581e157e18a3e0121eadb826055b2fde3f91ec188689cb/backports_zstd-1.6.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:65048ed08c5124f05ff9f355ab9703014bb2dbe7f8d9948ce193685b1775f442", size = 446683, upload-time = "2026-06-14T10:50:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/87cf3d883d386c10ac52f5322604fb9afdd204229f4c47d4a820a839b8ff/backports_zstd-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5918fc6b31437208721276964323933cd86077b8d5b469c59c1b3fd2c8220a05", size = 436869, upload-time = "2026-06-14T10:50:19.113Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/9479e6f0f18824ad38e8d7dd85161ab0842a198be669421232925bb30960/backports_zstd-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b6c8b02ab0ccb2431bb7bc238be91d158b308915e7b07937388e540466fe7e7", size = 363090, upload-time = "2026-06-14T10:50:20.302Z" }, + { url = "https://files.pythonhosted.org/packages/d9/74/a5e98fe108e17c91d9bc590a19e77f5d47d579e34d3f5bc098a949d6c27c/backports_zstd-1.6.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:711e6b98f8924e8b4a61ff97ab6321f33de024e1ed6a32f5123763aeda8459be", size = 507070, upload-time = "2026-06-14T10:50:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/392bb7dce7363b77bc5403060f418fad438b9cfdd3edd10d65cee7d8fd11/backports_zstd-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ba9ac10fc393e5123a08802e0e895a107cb4a66b9973d2844dbd8a343111e59", size = 477200, upload-time = "2026-06-14T10:50:22.91Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4d/dfb665806ba4f74bc48071d32006843b53568c4a17ff627a3061de5eaa09/backports_zstd-1.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f723219335387d7546412d8141e0303590600949b4184a1391a0c6a3c756058", size = 582724, upload-time = "2026-06-14T10:50:24.28Z" }, + { url = "https://files.pythonhosted.org/packages/57/b2/beeca7393a8310debd82ee2f0ce5c1801e8d7cb673f7f226f4a0866ca238/backports_zstd-1.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64b94d7a836568926a3309ff510c7f8261b881b341fd4992cabf4f0998878f8a", size = 643493, upload-time = "2026-06-14T10:50:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/38/26/ce90e9eed6f25aaa4a4fa305a2aaf2d2ad81fd69de8eb248ddd91c80d1e0/backports_zstd-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e39258a09b1c7ca70b5e94a5c5ccfe4700b4250b8077cfeab31d0f79565d4c9b", size = 492190, upload-time = "2026-06-14T10:50:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/17/9b/37b9b146df1f5452419a96071a7017cbac212ec9b137d7a88ca46dc2aa9e/backports_zstd-1.6.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15b1aae0f64cd742df4bba1d989d0a09a6ec619202543fdba684640454541fd3", size = 567432, upload-time = "2026-06-14T10:50:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/06/66/81b30991be83237529f36335ac3682bce26409064b906ac6122874575196/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25b5ddc789480072551af571a746e9500356b2aff0499861cf2ca07ea7431e68", size = 483021, upload-time = "2026-06-14T10:50:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/792c65dcc1e45eb0c1bdc012ee94b84867186bfe27a860d0813bd216f03b/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a13cfa3410a75e4cb87abdb669aaf79da861cb79299159054ff8f77b9671bc40", size = 510596, upload-time = "2026-06-14T10:50:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/1d/22/01b92a600505620e4cb5f20429e181f30458b7207ca8b52ca5ca6068c35f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2ddab55a5f54dec8acfad68ef70f1c704fd21919990ddc238afbd6f496e61c6a", size = 587143, upload-time = "2026-06-14T10:50:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/4672f5110b9eb01388cc6225a739e3a5fcd749a63a9c4c1450a04fa27113/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fa305a84087e10d7a85e8a8a3dcba8cdbda4868f2180173b264b7b488fd37c55", size = 565238, upload-time = "2026-06-14T10:50:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3b/19928d60ea7d25820bf12ef88de74534ca85b56ff7cf13c1b0e74e3a3d7c/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:df27b57d214a3124fbe4e933ef5a903d4567f154260d9aece8c797a987f2a205", size = 633970, upload-time = "2026-06-14T10:50:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/df/97/c4cecb3e0ff53563ef9819f0395d919ceaae9c5147392ac23bac7afdb20f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28fecd73459d74910ae1987ab84b7bef690d3dd860948430dd5555108b006daf", size = 496539, upload-time = "2026-06-14T10:50:37.015Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f4/46b2f29d2938a80e56e61a19f11ab093f531a9f8cd0ec8eeaac1246bcd99/backports_zstd-1.6.0-cp313-cp313-win32.whl", hash = "sha256:3e689af303df287142770abe3a48bbefd24dab4a09da5807d0e1fa8c75bab026", size = 291451, upload-time = "2026-06-14T10:50:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/b529f92166da61f496621345f95d2dc583c8ca5ac553c084a4ef6c12cd71/backports_zstd-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b067b1ef9c8e41fb0882c828aa37829938b5c0dab067eca72b23fc24c563b9da", size = 329023, upload-time = "2026-06-14T10:50:39.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/6be904d20345fbebec583ca83676e01f30c76118b283eb666d8ec8291ca1/backports_zstd-1.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:a838296f5b84c920172fb579cac894d255c1fc25457c7234613ddcfa385e49b7", size = 291636, upload-time = "2026-06-14T10:50:41.004Z" }, +] + [[package]] name = "backrefs" version = "7.0" @@ -124,6 +217,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "cfgraph" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/51/3e7e021920cfe2f7d18b672642e13f7dc4f53545d530b52ee6533b6681ca/CFGraph-0.2.1.tar.gz", hash = "sha256:b57fe7044a10b8ff65aa3a8a8ddc7d4cd77bf511b42e57289cd52cbc29f8fe74", size = 2630, upload-time = "2018-11-20T15:27:28.69Z" } + [[package]] name = "chardet" version = "7.4.3" @@ -249,6 +436,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "coverage" version = "7.14.0" @@ -333,6 +529,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] +[[package]] +name = "curies" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "pystow" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/b2/aa2f21c1ef42b046600538aa3221d84b1f5ee4199e383f2014624fc8e047/curies-0.14.6.tar.gz", hash = "sha256:17cde129aba90c151f38aae762b6c26fa2dceda8ed51103c829e805ee11d231c", size = 73340, upload-time = "2026-08-05T08:11:46.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/e9/bfa4d4a47c03180422343be8c69df75f5942276e815a367ae377f32db2d5/curies-0.14.6-py3-none-any.whl", hash = "sha256:a3653afa27576029c491e6ef97d2bc7d9c388afbbf44973e993b8d24fbb0c99c", size = 82629, upload-time = "2026-08-05T08:11:45.123Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + [[package]] name = "decorator" version = "5.3.1" @@ -342,6 +573,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "editorconfig" version = "0.17.1" @@ -351,6 +603,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -360,6 +621,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -372,6 +642,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -381,7 +660,9 @@ 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" }, @@ -389,7 +670,9 @@ 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" }, @@ -397,7 +680,9 @@ 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" }, @@ -405,14 +690,18 @@ 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" }, @@ -420,7 +709,9 @@ 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" }, @@ -436,6 +727,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "grimp" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864", size = 2144799, upload-time = "2026-07-03T12:08:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9", size = 2090802, upload-time = "2026-07-03T12:08:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5", size = 2255870, upload-time = "2026-07-03T12:07:37.207Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc", size = 2193359, upload-time = "2026-07-03T12:07:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545", size = 2346350, upload-time = "2026-07-03T12:08:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda", size = 2604959, upload-time = "2026-07-03T12:07:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851", size = 2327185, upload-time = "2026-07-03T12:08:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46", size = 2273241, upload-time = "2026-07-03T12:08:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7", size = 2433114, upload-time = "2026-07-03T12:09:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea", size = 2467235, upload-time = "2026-07-03T12:09:10.677Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3", size = 2506552, upload-time = "2026-07-03T12:09:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f", size = 2514255, upload-time = "2026-07-03T12:09:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7", size = 1856182, upload-time = "2026-07-03T12:09:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70", size = 1985451, upload-time = "2026-07-03T12:09:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5", size = 2253182, upload-time = "2026-07-03T12:07:39.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07", size = 2191822, upload-time = "2026-07-03T12:07:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5", size = 2344413, upload-time = "2026-07-03T12:08:21.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e", size = 2602856, upload-time = "2026-07-03T12:08:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0", size = 2326095, upload-time = "2026-07-03T12:08:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c", size = 2274375, upload-time = "2026-07-03T12:08:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b", size = 2430307, upload-time = "2026-07-03T12:09:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3", size = 2464014, upload-time = "2026-07-03T12:09:12.185Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5", size = 2502010, upload-time = "2026-07-03T12:09:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b", size = 2514787, upload-time = "2026-07-03T12:09:33Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66", size = 2345635, upload-time = "2026-07-03T12:08:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5", size = 2273647, upload-time = "2026-07-03T12:08:36.139Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6", size = 2344080, upload-time = "2026-07-03T12:08:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55", size = 2275041, upload-time = "2026-07-03T12:08:37.582Z" }, +] + +[[package]] +name = "hbreader" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/66/3a649ce125e03d1d43727a8b833cd211f0b9fe54a7e5be326f50d6f1d951/hbreader-0.9.1.tar.gz", hash = "sha256:d2c132f8ba6276d794c66224c3297cec25c8079d0a4cf019c061611e0a3b94fa", size = 19016, upload-time = "2021-02-25T19:22:32.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/24/61844afbf38acf419e01ca2639f7bd079584523d34471acbc4152ee991c5/hbreader-0.9.1-py3-none-any.whl", hash = "sha256:9a6e76c9d1afc1b977374a5dc430a1ebb0ea0488205546d4678d6e31cc5f6801", size = 7595, upload-time = "2021-02-25T19:22:31.944Z" }, +] + [[package]] name = "idna" version = "3.16" @@ -445,6 +809,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "import-linter" +version = "2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -454,6 +842,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + [[package]] name = "ipython" version = "9.13.0" @@ -488,6 +900,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + [[package]] name = "jedi" version = "0.20.0" @@ -525,6 +958,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, ] +[[package]] +name = "json-flattener" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/77/b00e46d904818826275661a690532d3a3a43a4ded0264b2d7fcdb5c0feea/json_flattener-0.1.9.tar.gz", hash = "sha256:84cf8523045ffb124301a602602201665fcb003a171ece87e6f46ed02f7f0c15", size = 11479, upload-time = "2022-02-26T01:36:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/cc/7fbd75d3362e939eb98bcf9bd22f3f7df8c237a85148899ed3d38e5614e5/json_flattener-0.1.9-py3-none-any.whl", hash = "sha256:6b027746f08bf37a75270f30c6690c7149d5f704d8af1740c346a3a1236bc941", size = 10799, upload-time = "2022-02-26T01:36:03.06Z" }, +] + +[[package]] +name = "jsonasobj" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/ba/13523c1408a23bac4e08ef2312732733c0129c4ff085d351eafaf45fd080/jsonasobj-1.3.1.tar.gz", hash = "sha256:d52e0544a54a08f6ea3f77fa3387271e3648655e0eace2f21e825c26370e44a2", size = 4315, upload-time = "2021-02-08T22:03:20.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/57/38c47753c67ad67f76ba04ea673c9b77431a19e7b2601937e6872a99e841/jsonasobj-1.3.1-py3-none-any.whl", hash = "sha256:b9e329dc1ceaae7cf5d5b214684a0b100e0dad0be6d5bbabac281ec35ddeca65", size = 4388, upload-time = "2021-02-08T22:03:19.17Z" }, +] + +[[package]] +name = "jsonasobj2" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hbreader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/3a/feb245b755f7a47a0df4f30be645e8485d15ff13d0c95e018e4505a8811f/jsonasobj2-1.0.4.tar.gz", hash = "sha256:f50b1668ef478004aa487b2d2d094c304e5cb6b79337809f4a1f2975cc7fbb4e", size = 95522, upload-time = "2021-06-02T17:43:28.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/90/0d93963711f811efe528e3cead2f2bfb78c196df74d8a24fe8d655288e50/jsonasobj2-1.0.4-py3-none-any.whl", hash = "sha256:12e86f86324d54fcf60632db94ea74488d5314e3da554c994fe1e2c6f29acb79", size = 6324, upload-time = "2021-06-02T17:43:27.126Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3987" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -585,6 +1130,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "linkml" +version = "1.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "click" }, + { name = "graphviz" }, + { name = "hbreader" }, + { name = "isodate" }, + { name = "jinja2" }, + { name = "jsonasobj2" }, + { name = "jsonschema", extra = ["format"] }, + { name = "linkml-runtime" }, + { name = "openpyxl" }, + { name = "parse" }, + { name = "prefixcommons" }, + { name = "prefixmaps" }, + { name = "pydantic" }, + { name = "pyjsg" }, + { name = "pyshex" }, + { name = "pyshexc" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "requests" }, + { name = "sphinx-click" }, + { name = "sqlalchemy" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/26/38e7340959cd4a87bfe5403cfcf5311d9fe2ff4382fa00e96008a1342760/linkml-1.11.1.tar.gz", hash = "sha256:2f6774e13628270cadaeecda3313db0437ecc15cd44ee35c6c2655dbe31c8524", size = 374853, upload-time = "2026-05-20T17:05:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/fb/3068f649cc436be915f51b2f5ac0656c83dc9bcc6d4f8940633e295042c0/linkml-1.11.1-py3-none-any.whl", hash = "sha256:d1bbb97a8b1ea4a99b145007875733a5e5e89b3acfe3e9d1e369fa4a582990ed", size = 483751, upload-time = "2026-05-20T17:05:38.663Z" }, +] + +[[package]] +name = "linkml-runtime" +version = "1.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "curies" }, + { name = "deprecated" }, + { name = "hbreader" }, + { name = "json-flattener" }, + { name = "jsonasobj2" }, + { name = "jsonschema" }, + { name = "prefixcommons" }, + { name = "prefixmaps" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/7c/36332b49226f37d05d0dbfa4fb1c8017963d62ae722102c9c11c1f530696/linkml_runtime-1.11.1.tar.gz", hash = "sha256:e71300b596c4f35aeccd9dca096806678402213dbdb2c5e8e68f507e21320754", size = 556549, upload-time = "2026-05-20T17:05:43.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/1d/600b0dd24aa61f03d35293a2e9a4695add1e94c03d8701436fb52d5daf4f/linkml_runtime-1.11.1-py3-none-any.whl", hash = "sha256:b22c77d8fd920d0f4f43a6ece31393dc0b28bb47790f3e1c114210318c36b3da", size = 654566, upload-time = "2026-05-20T17:05:40.526Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -883,6 +1487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -993,6 +1606,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "import-linter" }, { name = "ipython" }, { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -1008,9 +1622,13 @@ dev = [ postgres = [ { name = "psycopg", extra = ["binary"] }, ] +semantics = [ + { name = "omop-semantics" }, +] [package.metadata] requires-dist = [ + { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "ipython", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.1" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.7.1" }, @@ -1018,6 +1636,7 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "oa-configurator", specifier = "==0.1.2" }, { name = "oa-configurator", extras = ["dev", "postgres"], marker = "extra == 'dev'", specifier = "==0.1.2" }, + { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.5.0" }, { name = "orm-loader", specifier = "==0.5.3" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, @@ -1031,7 +1650,37 @@ requires-dist = [ { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.61" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["dev", "postgres"] +provides-extras = ["dev", "postgres", "semantics"] + +[[package]] +name = "omop-semantics" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "linkml" }, + { name = "linkml-runtime" }, + { name = "python-dotenv" }, + { name = "ruamel-yaml" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/b0/7bb5727f5dc38fc09f735a5a3b1e4c9023915671ce39927c06852e4639b7/omop_semantics-0.5.0.tar.gz", hash = "sha256:bca66c6d5e4705029df26780debaff4cdd9e9bd0121af88863ec402904ddb8d7", size = 45081, upload-time = "2026-08-05T11:13:53.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/7b/43910f583455b6e338234437e7f2d4a9b1d8b7425a2669582bef464a282f/omop_semantics-0.5.0-py3-none-any.whl", hash = "sha256:e298546b4a4ca0ac991620418b9d8b1abd5b76c4c94b47040057736e0ae9dc4d", size = 66221, upload-time = "2026-08-05T11:13:52.386Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] [[package]] name = "orm-loader" @@ -1119,6 +1768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] +[[package]] +name = "parse" +version = "1.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911", size = 36970, upload-time = "2026-05-26T03:44:52.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32", size = 20925, upload-time = "2026-05-26T03:44:51.156Z" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -1142,7 +1800,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] 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 = [ @@ -1167,6 +1825,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prefixcommons" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pytest-logging" }, + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/b5/c5b63a4bf5dedb36567181fdb98dbcc7aaa025faebabaaffa2f5eb4b8feb/prefixcommons-0.1.12.tar.gz", hash = "sha256:22c4e2d37b63487b3ab48f0495b70f14564cb346a15220f23919eb0c1851f69f", size = 24063, upload-time = "2022-07-19T00:06:12.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/e8/715b09df3dab02b07809d812042dc47a46236b5603d9d3a2572dbd1d8a97/prefixcommons-0.1.12-py3-none-any.whl", hash = "sha256:16dbc0a1f775e003c724f19a694fcfa3174608f5c8b0e893d494cf8098ac7f8b", size = 29482, upload-time = "2022-07-19T00:06:08.709Z" }, +] + +[[package]] +name = "prefixmaps" +version = "0.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curies" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/cf/f588bcdfd2c841839b9d59ce219a46695da56aa2805faff937bbafb9ee2b/prefixmaps-0.2.6.tar.gz", hash = "sha256:7421e1244eea610217fa1ba96c9aebd64e8162a930dc0626207cd8bf62ecf4b9", size = 709899, upload-time = "2024-10-17T16:30:57.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b2/2b2153173f2819e3d7d1949918612981bc6bd895b75ffa392d63d115f327/prefixmaps-0.2.6-py3-none-any.whl", hash = "sha256:f6cef28a7320fc6337cf411be212948ce570333a0ce958940ef684c7fb192a62", size = 754732, upload-time = "2024-10-17T16:30:55.731Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1326,6 +2012,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1425,6 +2120,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjsg" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "jsonasobj" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/4e/192169ba1066454f016927fc46c7e595a6c701fd1173bd249efcf6de40b3/pyjsg-0.12.4.tar.gz", hash = "sha256:bb1c0ff1f50846d2b5185b182e28b0b6978eae51a2078ce3eb1e0f28dea7b9ab", size = 149852, upload-time = "2026-05-01T14:43:44.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/05/d129d016f5124adb882816bdaef44bb877e313ceb0a109abcf553f1ac90c/pyjsg-0.12.4-py3-none-any.whl", hash = "sha256:a57ae58bfd7192b32654a0024bc6462fb459d54e837f0b2b5cff0726aad2e557", size = 81728, upload-time = "2026-05-01T14:43:43.394Z" }, +] + [[package]] name = "pymdown-extensions" version = "10.21.3" @@ -1438,6 +2147,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyshex" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgraph" }, + { name = "chardet" }, + { name = "pyshexc" }, + { name = "rdflib-shim" }, + { name = "requests" }, + { name = "shexjsg" }, + { name = "sparqlslurper" }, + { name = "sparqlwrapper" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/ca/f0e6ecd16e65318f69fe5937982955c340a9e5828dcb391371100577c174/pyshex-0.9.0.tar.gz", hash = "sha256:87288b5e5613f734f55f0085334558218ff618fb1061aabdcee19841092b3eca", size = 508959, upload-time = "2026-05-07T12:55:05.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/ea/66c21d1f5fec82e6218a70b5672870f76878f41bf3b9570235b4e7223118/pyshex-0.9.0-py3-none-any.whl", hash = "sha256:d81344deed686b7c169f23156221ae281225e2ba02b14fe9810335afdefffa9d", size = 54742, upload-time = "2026-05-07T12:55:03.803Z" }, +] + +[[package]] +name = "pyshexc" +version = "0.10.3.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "chardet" }, + { name = "jsonasobj" }, + { name = "pyjsg" }, + { name = "rdflib-shim" }, + { name = "shexjsg" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/c5/81196cd2ab23953c4a8d39c7627437984bf8566eae896c5745356a7de7c8/pyshexc-0.10.3.post1.tar.gz", hash = "sha256:80d9d067c80af9a796e3c1c47d2207edf2e9a9fc39d3ca0ce5dd2019334ea915", size = 130019, upload-time = "2026-05-01T11:34:19.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1d/d8d5be9e72e518b42f544e196de9c07161b0933143c9d0e4e2e33de60d79/pyshexc-0.10.3.post1-py3-none-any.whl", hash = "sha256:5d247f2822ef9864152545935d93a07dce66640608ea9414c96f69da7fe7a168", size = 71730, upload-time = "2026-05-01T11:34:17.836Z" }, +] + +[[package]] +name = "pystow" +version = "0.8.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/08/e572a7b66ba91b5335bca61afa2250d6419dc391ebf3cb5ac8795748dee0/pystow-0.8.21.tar.gz", hash = "sha256:460c299093d3e6f45433141ba0c5bc5d99c7fe98b042a6f578040d5103db7aab", size = 54881, upload-time = "2026-07-04T10:25:21.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ac/991733a89b74f62d05d3ad6da773e2d36e26a3a71b6ee6b9ef82b450b486/pystow-0.8.21-py3-none-any.whl", hash = "sha256:7b14f77f0395b93a3a94d85526972c98d526d86a8efa5036363b9da3e2d34003", size = 62430, upload-time = "2026-07-04T10:25:20.274Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -1468,6 +2237,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-logging" +version = "2015.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1e/fb11174c9eaebcec27d36e9e994b90ffa168bc3226925900b9dbbf16c9da/pytest-logging-2015.11.4.tar.gz", hash = "sha256:cec5c85ecf18aab7b2ead5498a31b9f758680ef5a902b9054ab3f2bdbb77c896", size = 3916, upload-time = "2015-11-04T12:15:54.122Z" } + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1480,6 +2258,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1538,6 +2325,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + +[[package]] +name = "rdflib" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, +] + +[[package]] +name = "rdflib-jsonld" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/48/9eaecac5f5ba6b31dd932fbbe67206afcbd24a7a696c03c6c920ac7ddc39/rdflib-jsonld-0.6.1.tar.gz", hash = "sha256:eda5a42a2e09f80d4da78e32b5c684bccdf275368f1541e6b7bcddfb1382a0e0", size = 130465, upload-time = "2021-09-14T12:22:20.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/d2/760527679057a7dad67f4e41f3e0c463b247f0bdbffc594e0add7c9077d6/rdflib_jsonld-0.6.1-py2.py3-none-any.whl", hash = "sha256:bcf84317e947a661bae0a3f2aee1eced697075fc4ac4db6065a3340ea0f10fc2", size = 16381, upload-time = "2021-09-14T12:22:17.805Z" }, +] + +[[package]] +name = "rdflib-shim" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, + { name = "rdflib-jsonld" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/c8/1014ec6b5f4428c630deffba1f9851043ae378eb1d6ef52a03bd492cea99/rdflib_shim-1.0.3.tar.gz", hash = "sha256:d955d11e2986aab42b6830ca56ac6bc9c893abd1d049a161c6de2f1b99d4fc0d", size = 7783, upload-time = "2021-12-21T16:31:06.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/97/d8a785d2c7131c731c90cb0e65af9400081af4380bea4ec04868dc21aa92/rdflib_shim-1.0.3-py3-none-any.whl", hash = "sha256:7a853e7750ef1e9bf4e35dea27d54e02d4ed087de5a9e0c329c4a6d82d647081", size = 5190, upload-time = "2021-12-21T16:31:05.719Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1553,6 +2434,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3987" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700, upload-time = "2018-07-29T17:23:47.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" }, +] + [[package]] name = "rich" version = "14.3.4" @@ -1566,6 +2468,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, ] +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.15.14" @@ -1609,6 +2625,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "shexjsg" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjsg" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/d6/6e948660a888a37a5100e7bf7109e89b249ed526df430b674da12950de17/shexjsg-0.9.0.tar.gz", hash = "sha256:750016fabdb5487b27e2e714145f3602cd3ac4eb0dd9b7d7751d0cde62c0d1d8", size = 65164, upload-time = "2026-05-04T13:34:38.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/72/b03ca1560615933f079ba7d291d3532ed95c2a3205911fe71d192654acaa/shexjsg-0.9.0-py3-none-any.whl", hash = "sha256:abf18db2d9895bc46740f68ae699b2ccfe08c783f6e0c038e6077293ad01c0a5", size = 15344, upload-time = "2026-05-04T13:34:36.955Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1618,6 +2646,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + [[package]] name = "soupsieve" version = "2.8.4" @@ -1627,6 +2664,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] +[[package]] +name = "sparqlslurper" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, + { name = "rdflib-shim" }, + { name = "sparqlwrapper" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/22/6c375a48851f96b334e147db62ebee615283b87f30398ba94b3551d60984/sparqlslurper-0.5.1.tar.gz", hash = "sha256:9282ebb064fc6152a58269d194cb1e7b275b0f095425a578d75b96dcc851f546", size = 640336, upload-time = "2021-12-21T21:28:04.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/77/48ce09fce2836856588beb84f434c1f8812d1428326efd993b619d49d949/sparqlslurper-0.5.1-py3-none-any.whl", hash = "sha256:ae49b2d8ce3dd38df7a40465b228ad5d33fb7e11b3f248d195f9cadfc9cfff87", size = 6555, upload-time = "2021-12-21T21:28:01.95Z" }, +] + +[[package]] +name = "sparqlwrapper" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/cc/453752fffa759ef41a3ceadb3f167e13dae1a74c1db057d9f6a7affa9240/SPARQLWrapper-2.0.0.tar.gz", hash = "sha256:3fed3ebcc77617a4a74d2644b86fd88e0f32e7f7003ac7b2b334c026201731f1", size = 98429, upload-time = "2022-03-13T23:14:00.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/89/176e3db96e31e795d7dfd91dd67749d3d1f0316bb30c6931a6140e1a0477/SPARQLWrapper-2.0.0-py3-none-any.whl", hash = "sha256:c99a7204fff676ee28e6acef327dc1ff8451c6f7217dcd8d49e8872f324a8a20", size = 28620, upload-time = "2022-03-13T23:13:58.969Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-click" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ed/a9767cd1b8b7fbdf260a89d5c8c86e20e3536b9878579e5ab7965a291e55/sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c", size = 27035, upload-time = "2025-12-04T19:33:05.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/bd/cb244695f67f77b0a36200ce1670fc42a6fe2770847e870daab99cc2b177/sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7", size = 8939, upload-time = "2025-12-04T19:33:04.037Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.50" @@ -1691,6 +2850,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "traitlets" version = "5.15.0" @@ -1770,6 +2958,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1811,3 +3008,76 @@ sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47 wheels = [ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] From c6a63acb5713e32ef3149a96550fccbcadcb52e1 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 16:51:52 +1000 Subject: [PATCH 4/7] adding oncology-specific handling under new toolkit structure --- .../analytics/adverse_events/__init__.py | 15 ++ .../toolkit/analytics/adverse_events/ctcae.py | 27 ++ .../analytics/adverse_events/weight_loss.py | 78 ++++++ .../analytics/body_metrics/__init__.py | 39 +++ .../analytics/body_metrics/calculators.py | 80 ++++++ .../analytics/body_metrics/concept_sets.py | 20 ++ .../body_metrics/measurement_series.py | 159 ++++++++++++ .../body_metrics/weight_trajectory.py | 241 ++++++++++++++++++ .../toolkit/analytics/oncology/__init__.py | 82 ++++++ .../analytics/oncology/concept_sets.py | 220 ++++++++++++++++ .../oncology/oncology_critical_weight_loss.py | 52 ++++ .../oncology/oncology_drug_exposure.py | 38 +++ .../analytics/oncology/oncology_episodes.py | 212 +++++++++++++++ .../analytics/oncology/oncology_event.py | 55 ++++ .../oncology/oncology_procedure_occurrence.py | 82 ++++++ .../analytics/oncology/oncology_rt_dosing.py | 122 +++++++++ .../oncology/oncology_sact_dosing.py | 132 ++++++++++ .../toolkit/episodes/handling/__init__.py | 23 ++ .../toolkit/episodes/handling/dosing.py | 22 ++ .../toolkit/episodes/handling/drug_episode.py | 37 +++ .../episodes/handling/event_windowing.py | 31 +++ .../episodes/handling/exposure_series.py | 61 +++++ .../toolkit/episodes/handling/summaries.py | 69 +++++ tests/test_adverse_events.py | 40 +++ tests/test_body_metrics.py | 113 ++++++++ 25 files changed, 2050 insertions(+) create mode 100644 omop_alchemy/toolkit/analytics/adverse_events/ctcae.py create mode 100644 omop_alchemy/toolkit/analytics/adverse_events/weight_loss.py create mode 100644 omop_alchemy/toolkit/analytics/body_metrics/calculators.py create mode 100644 omop_alchemy/toolkit/analytics/body_metrics/concept_sets.py create mode 100644 omop_alchemy/toolkit/analytics/body_metrics/measurement_series.py create mode 100644 omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/concept_sets.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_critical_weight_loss.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_drug_exposure.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_episodes.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_event.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_procedure_occurrence.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py create mode 100644 omop_alchemy/toolkit/analytics/oncology/oncology_sact_dosing.py create mode 100644 omop_alchemy/toolkit/episodes/handling/dosing.py create mode 100644 omop_alchemy/toolkit/episodes/handling/drug_episode.py create mode 100644 omop_alchemy/toolkit/episodes/handling/event_windowing.py create mode 100644 omop_alchemy/toolkit/episodes/handling/exposure_series.py create mode 100644 omop_alchemy/toolkit/episodes/handling/summaries.py create mode 100644 tests/test_adverse_events.py create mode 100644 tests/test_body_metrics.py diff --git a/omop_alchemy/toolkit/analytics/adverse_events/__init__.py b/omop_alchemy/toolkit/analytics/adverse_events/__init__.py index 6710c36..9cfe1f0 100644 --- a/omop_alchemy/toolkit/analytics/adverse_events/__init__.py +++ b/omop_alchemy/toolkit/analytics/adverse_events/__init__.py @@ -29,3 +29,18 @@ come from — which readings were admitted, over what window — is decided in ``omop_alchemy.toolkit.analytics.body_metrics``. """ + +from .ctcae import CTCAEWeightLoss, ctcae_weight_loss_grade +from .weight_loss import ( + MartinWeightLoss, + critical_weight_loss_grade, + martin_weight_loss_grade, +) + +__all__ = [ + "CTCAEWeightLoss", + "MartinWeightLoss", + "critical_weight_loss_grade", + "ctcae_weight_loss_grade", + "martin_weight_loss_grade", +] diff --git a/omop_alchemy/toolkit/analytics/adverse_events/ctcae.py b/omop_alchemy/toolkit/analytics/adverse_events/ctcae.py new file mode 100644 index 0000000..83a1285 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/adverse_events/ctcae.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + + +class CTCAEWeightLoss: + """ + CTCAE-style weight-loss severity from percent weight change only. + + This implements physiological percent-loss bins. + + CTCAE intervention qualifiers such as hospitalisation, tube feeding, or TPN + are not inferred here. + """ + + THRESHOLDS: ClassVar[tuple[float, ...]] = (5.0, 10.0, 20.0) + + @classmethod + def grade(cls, pct_change: Optional[float]) -> Optional[int]: + if pct_change is None: + return None + loss_pct = max(0.0, -pct_change) + return sum(1 for threshold in cls.THRESHOLDS if loss_pct >= threshold) + + +def ctcae_weight_loss_grade(pct_change: Optional[float]) -> Optional[int]: + return CTCAEWeightLoss.grade(pct_change) diff --git a/omop_alchemy/toolkit/analytics/adverse_events/weight_loss.py b/omop_alchemy/toolkit/analytics/adverse_events/weight_loss.py new file mode 100644 index 0000000..cabdd47 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/adverse_events/weight_loss.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + +from .ctcae import ctcae_weight_loss_grade + + +class MartinWeightLoss: + """ + Martin et al. BMI-adjusted percent-weight-loss grading. + + This matrix is kept in adverse events because it is clinical + severity policy over body measurements, not body-measurement + arithmetic itself. + """ + + BMI_CATEGORY_BOUNDARIES: ClassVar[tuple[float, ...]] = (20.0, 22.0, 25.0, 28.0) + WEIGHT_LOSS_CATEGORY_BOUNDARIES: ClassVar[tuple[float, ...]] = ( + 2.5, + 6.0, + 11.0, + 15.0, + ) + GRADE_MATRIX: ClassVar[tuple[tuple[int, int, int, int, int], ...]] = ( + (0, 0, 1, 1, 3), + (1, 2, 2, 2, 3), + (2, 3, 3, 3, 4), + (3, 3, 4, 4, 4), + (3, 4, 4, 4, 4), + ) + + @classmethod + def _bmi_category_index(cls, bmi: float) -> int: + for index, boundary in enumerate(reversed(cls.BMI_CATEGORY_BOUNDARIES)): + if bmi >= boundary: + return index + return len(cls.BMI_CATEGORY_BOUNDARIES) + + @classmethod + def _weight_loss_category_index(cls, loss_pct: float) -> int: + for index, boundary in enumerate(cls.WEIGHT_LOSS_CATEGORY_BOUNDARIES): + if loss_pct < boundary: + return index + return len(cls.WEIGHT_LOSS_CATEGORY_BOUNDARIES) + + @classmethod + def grade( + cls, + pct_change: Optional[float], + bmi: Optional[float], + ) -> Optional[int]: + if pct_change is None or bmi is None: + return None + loss_pct = max(0.0, -pct_change) + return cls.GRADE_MATRIX[ + cls._weight_loss_category_index(loss_pct) + ][cls._bmi_category_index(bmi)] + + +def martin_weight_loss_grade( + pct_change: Optional[float], + bmi: Optional[float], +) -> Optional[int]: + return MartinWeightLoss.grade(pct_change, bmi) + + +def critical_weight_loss_grade( + pct_change: Optional[float], + bmi: Optional[float], +) -> Optional[int]: + """ + Critical-weight-loss grade using Martin where BMI is available. + + Falls back to CTCAE-style percent-weight-loss grading when percent change is + evaluable but BMI is not, preserving coverage without guessing BMI. + """ + grade = martin_weight_loss_grade(pct_change, bmi) + return grade if grade is not None else ctcae_weight_loss_grade(pct_change) diff --git a/omop_alchemy/toolkit/analytics/body_metrics/__init__.py b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py index c869c85..80fe1db 100644 --- a/omop_alchemy/toolkit/analytics/body_metrics/__init__.py +++ b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py @@ -41,3 +41,42 @@ class MyEpisode(WeightTrajectoryMixin, EpisodeView): Severity grading of a weight change is a separate concern and lives in ``omop_alchemy.toolkit.analytics.adverse_events``. """ + +from .calculators import BodyMetricRules, default_body_metric_rules +from .concept_sets import ( + BodySizeMeasurementConcepts, + default_body_size_measurement_concepts, +) +from .measurement_series import ( + MeasurementReading, + MeasurementSeriesMixin, + ReadingSource, + reading_from_measurement, + resolve_measurement_series, + resolve_person_measurement_series, +) +from .weight_trajectory import ( + WeightChange, + WeightTrajectoryMixin, + WeightTrajectoryPoint, + normalize_height_readings, + normalize_weight_readings, +) + +__all__ = [ + "BodyMetricRules", + "BodySizeMeasurementConcepts", + "MeasurementReading", + "MeasurementSeriesMixin", + "ReadingSource", + "WeightChange", + "WeightTrajectoryMixin", + "WeightTrajectoryPoint", + "default_body_metric_rules", + "default_body_size_measurement_concepts", + "normalize_height_readings", + "normalize_weight_readings", + "reading_from_measurement", + "resolve_measurement_series", + "resolve_person_measurement_series", +] diff --git a/omop_alchemy/toolkit/analytics/body_metrics/calculators.py b/omop_alchemy/toolkit/analytics/body_metrics/calculators.py new file mode 100644 index 0000000..2d51900 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/body_metrics/calculators.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import lru_cache +from typing import Optional + +from omop_alchemy.toolkit.core.units import ( + BodySizeUnitConcepts, + BodyUnitConversionRules, + default_body_unit_conversion_rules, +) + +from .concept_sets import ( + BodySizeMeasurementConcepts, + default_body_size_measurement_concepts, +) + + +@dataclass(frozen=True) +class BodyMetricRules: + """ + Anthropometric unit normalisation and body-size formula rules. + """ + + measurements: BodySizeMeasurementConcepts + unit_rules: BodyUnitConversionRules + + @classmethod + def default(cls) -> "BodyMetricRules": + return default_body_metric_rules() + + @property + def units(self) -> BodySizeUnitConcepts: + return self.unit_rules.units + + @property + def weight_concept_id(self) -> int: + return self.measurements.weight + + @property + def height_concept_id(self) -> int: + return self.measurements.height + + def normalize_weight_kg( + self, + value: Optional[float], + unit_concept_id: Optional[int], + ) -> Optional[float]: + return self.unit_rules.normalize_weight_kg(value, unit_concept_id) + + def normalize_height_cm( + self, + value: Optional[float], + unit_concept_id: Optional[int], + ) -> Optional[float]: + return self.unit_rules.normalize_height_cm(value, unit_concept_id) + + @staticmethod + def bmi(weight_kg: Optional[float], height_m: Optional[float]) -> Optional[float]: + if not weight_kg or not height_m: + return None + return weight_kg / (height_m**2) + + @staticmethod + def bsa_mosteller_m2( + weight_kg: Optional[float], + height_cm: Optional[float], + ) -> Optional[float]: + if not weight_kg or not height_cm: + return None + return math.sqrt((height_cm * weight_kg) / 3600.0) + + +@lru_cache(maxsize=1) +def default_body_metric_rules() -> BodyMetricRules: + return BodyMetricRules( + measurements=default_body_size_measurement_concepts(), + unit_rules=default_body_unit_conversion_rules(), + ) diff --git a/omop_alchemy/toolkit/analytics/body_metrics/concept_sets.py b/omop_alchemy/toolkit/analytics/body_metrics/concept_sets.py new file mode 100644 index 0000000..b13332b --- /dev/null +++ b/omop_alchemy/toolkit/analytics/body_metrics/concept_sets.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from omop_alchemy.toolkit.core._semantics import default_semantics_runtime + + +@dataclass(frozen=True) +class BodySizeMeasurementConcepts: + weight: int + height: int + + +def default_body_size_measurement_concepts() -> BodySizeMeasurementConcepts: + runtime = default_semantics_runtime() + measurements = runtime.measurements_numeric.body_size_measurements + return BodySizeMeasurementConcepts( + weight=measurements.weight, + height=measurements.height, + ) diff --git a/omop_alchemy/toolkit/analytics/body_metrics/measurement_series.py b/omop_alchemy/toolkit/analytics/body_metrics/measurement_series.py new file mode 100644 index 0000000..2e85a06 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/body_metrics/measurement_series.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from functools import cached_property +from typing import ClassVar, Literal, Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.orm import object_session + +from omop_alchemy.cdm.model import Measurement +from omop_alchemy.toolkit.episodes.handling import episode_attachment_window + +ReadingSource = Literal["explicit", "window", "person"] + + +@dataclass(frozen=True) +class MeasurementReading: + """A resolved numeric measurement reduced to the fields trajectory math needs.""" + + measurement_id: int + date: date + value: Optional[float] + unit_concept_id: Optional[int] + concept_id: int + source: ReadingSource + + +def reading_from_measurement( + measurement: Measurement, + *, + source: ReadingSource, +) -> MeasurementReading: + return MeasurementReading( + measurement_id=measurement.measurement_id, + date=measurement.measurement_date, + value=measurement.value_as_number, + unit_concept_id=measurement.unit_concept_id, + concept_id=measurement.measurement_concept_id, + source=source, + ) + + +def _explicit_episode_measurements( + episode, + concept_id_set: set[int], + *, + exclude_modifiers: bool, +) -> tuple[list[MeasurementReading], set[int]]: + seen_ids: set[int] = set() + readings: list[MeasurementReading] = [] + + for event in episode.events: + if not isinstance(event, Measurement): + continue + if event.measurement_concept_id not in concept_id_set: + continue + if exclude_modifiers and event.measurement_event_id is not None: + continue + readings.append(reading_from_measurement(event, source="explicit")) + seen_ids.add(event.measurement_id) + + return readings, seen_ids + + +def resolve_measurement_series( + episode, + concept_ids: Sequence[int], + *, + exclude_modifiers: bool = True, +) -> list[MeasurementReading]: + """ + Resolve episode-attributable measurements for the supplied concepts. + + The resolver first honours explicit ``Episode_Event`` links, then adds + same-person measurements in the episode attachment window that were not + already seen. Detached episodes simply return explicit links. + """ + concept_id_set = set(concept_ids) + readings, seen_ids = _explicit_episode_measurements( + episode, + concept_id_set, + exclude_modifiers=exclude_modifiers, + ) + + session = object_session(episode) + if session is not None: + window_start, window_end = episode_attachment_window(episode) + stmt = select(Measurement).where( + Measurement.person_id == episode.person_id, + Measurement.measurement_concept_id.in_(concept_id_set), + Measurement.measurement_date.between(window_start, window_end), + ) + if exclude_modifiers: + stmt = stmt.where(Measurement.measurement_event_id.is_(None)) + + for row in session.execute(stmt).scalars(): + if row.measurement_id in seen_ids: + continue + readings.append(reading_from_measurement(row, source="window")) + seen_ids.add(row.measurement_id) + + readings.sort(key=lambda r: r.date) + return readings + + +def resolve_person_measurement_series( + episode, + concept_ids: Sequence[int], + *, + exclude_modifiers: bool = True, +) -> list[MeasurementReading]: + """ + Resolve all same-person measurements for effectively invariant concepts. + + This is appropriate for adult height, where an out-of-window reading can + still validly support BMI/BSA for an episode. Do not use it for weight. + """ + concept_id_set = set(concept_ids) + readings, seen_ids = _explicit_episode_measurements( + episode, + concept_id_set, + exclude_modifiers=exclude_modifiers, + ) + + session = object_session(episode) + if session is not None: + stmt = select(Measurement).where( + Measurement.person_id == episode.person_id, + Measurement.measurement_concept_id.in_(concept_id_set), + ) + if exclude_modifiers: + stmt = stmt.where(Measurement.measurement_event_id.is_(None)) + + for row in session.execute(stmt).scalars(): + if row.measurement_id in seen_ids: + continue + readings.append(reading_from_measurement(row, source="person")) + seen_ids.add(row.measurement_id) + + readings.sort(key=lambda r: r.date) + return readings + + +class MeasurementSeriesMixin: + """ + Mixin for episode views that need one cached measurement concept series. + """ + + _series_concept_ids: ClassVar[Sequence[int]] + _exclude_modifier_measurements: ClassVar[bool] = True + + @cached_property + def _series(self) -> list[MeasurementReading]: + return resolve_measurement_series( + self, + self._series_concept_ids, + exclude_modifiers=self._exclude_modifier_measurements, + ) diff --git a/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py new file mode 100644 index 0000000..ad08d7a --- /dev/null +++ b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import date, timedelta +from functools import cached_property +from typing import ClassVar, Optional + +from .calculators import BodyMetricRules +from .measurement_series import ( + MeasurementReading, + resolve_measurement_series, + resolve_person_measurement_series, +) + + +@dataclass(frozen=True) +class WeightChange: + """ + Result of a windowed weight-change computation. + + ``pct_change`` is None whenever the change is not evaluable. Keeping that + explicit prevents callers from folding "unknown" into "no loss". + """ + + pct_change: Optional[float] + evaluable: bool + reference: Optional[MeasurementReading] = None + + @classmethod + def not_evaluable(cls) -> "WeightChange": + return cls(pct_change=None, evaluable=False) + + +@dataclass(frozen=True) +class WeightTrajectoryPoint: + day_offset: int + pct_change: float + date: date + weight_kg: float + + +def normalize_weight_readings( + readings: list[MeasurementReading], + rules: BodyMetricRules, +) -> list[MeasurementReading]: + normalized: list[MeasurementReading] = [] + for reading in readings: + kg = rules.normalize_weight_kg(reading.value, reading.unit_concept_id) + if kg is None: + continue + normalized.append(replace(reading, value=kg, unit_concept_id=rules.units.kg)) + return normalized + + +def normalize_height_readings( + readings: list[MeasurementReading], + rules: BodyMetricRules, +) -> list[MeasurementReading]: + normalized: list[MeasurementReading] = [] + for reading in readings: + cm = rules.normalize_height_cm(reading.value, reading.unit_concept_id) + if cm is None: + continue + normalized.append(replace(reading, value=cm, unit_concept_id=rules.units.cm)) + return normalized + + +class WeightTrajectoryMixin: + """ + Episode mixin exposing normalized weight, height, BMI, and trajectories. + + Subclasses may set ``_body_metric_rules`` to avoid loading the default + omop-semantics-backed concept IDs. + """ + + _body_metric_rules: ClassVar[BodyMetricRules | None] = None + _exclude_modifier_measurements: ClassVar[bool] = True + episode_id: ClassVar[int] + person_id: ClassVar[int] + + @classmethod + def body_metric_rules(cls) -> BodyMetricRules: + return cls._body_metric_rules or BodyMetricRules.default() + + @cached_property + def _raw_weight_series(self) -> list[MeasurementReading]: + rules = self.body_metric_rules() + return resolve_measurement_series( + self, + (rules.weight_concept_id,), + exclude_modifiers=self._exclude_modifier_measurements, + ) + + @cached_property + def _raw_height_series(self) -> list[MeasurementReading]: + rules = self.body_metric_rules() + return resolve_person_measurement_series( + self, + (rules.height_concept_id,), + exclude_modifiers=self._exclude_modifier_measurements, + ) + + @cached_property + def weight_readings(self) -> list[MeasurementReading]: + """Weight readings normalized to kg.""" + return normalize_weight_readings(self._raw_weight_series, self.body_metric_rules()) + + @cached_property + def height_readings_cm(self) -> list[MeasurementReading]: + """Height readings normalized to cm and resolved without an episode date window.""" + return normalize_height_readings(self._raw_height_series, self.body_metric_rules()) + + @property + def height_m(self) -> Optional[float]: + readings = self.height_readings_cm + if not readings or readings[0].value is None: + return None + return readings[0].value / 100.0 + + @property + def baseline_weight(self) -> Optional[MeasurementReading]: + return self.weight_readings[0] if self.weight_readings else None + + @property + def latest_weight(self) -> Optional[MeasurementReading]: + return self.weight_readings[-1] if self.weight_readings else None + + @property + def baseline_bmi(self) -> Optional[float]: + baseline = self.baseline_weight + return self.body_metric_rules().bmi( + baseline.value if baseline else None, + self.height_m, + ) + + @property + def baseline_bsa_mosteller_m2(self) -> Optional[float]: + baseline = self.baseline_weight + height = self.height_readings_cm[0] if self.height_readings_cm else None + return self.body_metric_rules().bsa_mosteller_m2( + baseline.value if baseline else None, + height.value if height else None, + ) + + def pct_change_from_baseline( + self, + as_of: Optional[MeasurementReading] = None, + ) -> WeightChange: + baseline = self.baseline_weight + target = as_of or self.latest_weight + if ( + baseline is None + or target is None + or baseline.measurement_id == target.measurement_id + or baseline.value is None + or target.value is None + ): + return WeightChange.not_evaluable() + pct = 100.0 * (target.value - baseline.value) / baseline.value + return WeightChange(pct_change=pct, evaluable=True, reference=baseline) + + def pct_change_over(self, days: int) -> WeightChange: + readings = self.weight_readings + if len(readings) < 2: + return WeightChange.not_evaluable() + latest = readings[-1] + window_start = latest.date - timedelta(days=days) + candidates = [r for r in readings if r.date >= window_start] + earliest_in_window = candidates[0] + if ( + latest.value is None + or earliest_in_window.value is None + or earliest_in_window.measurement_id == latest.measurement_id + ): + return WeightChange.not_evaluable() + pct = 100.0 * (latest.value - earliest_in_window.value) / earliest_in_window.value + return WeightChange(pct_change=pct, evaluable=True, reference=earliest_in_window) + + def pct_change_trajectory(self) -> list[WeightTrajectoryPoint]: + baseline = self.baseline_weight + if baseline is None or baseline.value is None: + return [] + points: list[WeightTrajectoryPoint] = [] + for reading in self.weight_readings: + if reading.value is None: + continue + day_offset = (reading.date - baseline.date).days + pct = 100.0 * (reading.value - baseline.value) / baseline.value + points.append( + WeightTrajectoryPoint( + day_offset=day_offset, + pct_change=pct, + date=reading.date, + weight_kg=reading.value, + ) + ) + return points + + def sustained_loss( + self, + threshold_pct: float = 5.0, + min_consecutive: int = 2, + ) -> Optional[bool]: + baseline = self.baseline_weight + readings = self.weight_readings + if baseline is None or baseline.value is None or len(readings) < min_consecutive: + return None + tail = readings[-min_consecutive:] + if any(r.value is None for r in tail): + return None + return all( + r.value is not None + and (100.0 * (baseline.value - r.value) / baseline.value) >= threshold_pct + for r in tail + ) + + def weight_trajectory_summary(self) -> dict: + baseline = self.baseline_weight + latest = self.latest_weight + change_from_baseline = self.pct_change_from_baseline() + change_3mo = self.pct_change_over(90) + change_6mo = self.pct_change_over(180) + return { + "episode_id": self.episode_id, + "person_id": self.person_id, + "n_weight_readings": len(self.weight_readings), + "baseline_weight_kg": baseline.value if baseline else None, + "baseline_weight_date": baseline.date if baseline else None, + "latest_weight_kg": latest.value if latest else None, + "latest_weight_date": latest.date if latest else None, + "height_m": self.height_m, + "baseline_bmi": self.baseline_bmi, + "baseline_bsa_mosteller_m2": self.baseline_bsa_mosteller_m2, + "pct_change_from_baseline": change_from_baseline.pct_change, + "pct_change_from_baseline_evaluable": change_from_baseline.evaluable, + "pct_change_3mo": change_3mo.pct_change, + "pct_change_3mo_evaluable": change_3mo.evaluable, + "pct_change_6mo": change_6mo.pct_change, + "pct_change_6mo_evaluable": change_6mo.evaluable, + "sustained_5pct_loss": self.sustained_loss(), + } diff --git a/omop_alchemy/toolkit/analytics/oncology/__init__.py b/omop_alchemy/toolkit/analytics/oncology/__init__.py index ea01f5b..27d545c 100644 --- a/omop_alchemy/toolkit/analytics/oncology/__init__.py +++ b/omop_alchemy/toolkit/analytics/oncology/__init__.py @@ -42,3 +42,85 @@ dependency. Install ``omop-alchemy[semantics]`` to use them; supply your own concept sets otherwise. """ + +from .concept_sets import ( + cancer_indicating_surgery_parent_concept_ids, + cancer_indicating_surgery_point_concept_ids, + clear_concept_set_cache, + diagnostic_staging_procedure_parent_concept_ids, + diagnostic_staging_procedure_point_concept_ids, + disease_episode_type_concept_ids, + drug_concept_membership_expression, + overarching_episode_type_concept_id, + procedure_concept_membership_expression, + resolve_cancer_indicating_surgery_procedure_concept_ids, + resolve_diagnostic_staging_procedure_concept_ids, + resolve_rt_procedure_concept_ids, + resolve_sact_drug_concept_ids, + rt_procedure_parent_concept_ids, + sact_drug_excluded_parent_concept_ids, + sact_drug_parent_concept_ids, + treatment_cycle_episode_concept_id, + treatment_episode_type_concept_ids, + treatment_regimen_episode_concept_id, +) +from .oncology_critical_weight_loss import OncologyCriticalWeightLossMixin +from .oncology_drug_exposure import OncologyDrugExposure +from .oncology_episodes import OncologyEpisode, OncologyModality +from .oncology_event import OncologyEpisodeEvent, OncologyEpisodeEventMixin +from .oncology_procedure_occurrence import OncologyProcedure +from .oncology_rt_dosing import ( + OncologyRTDosingMixin, + RTDoseSummary, + rt_dose_evaluability, + rt_site_key, + summarize_rt_procedures, + summarize_rt_procedures_by, +) +from .oncology_sact_dosing import ( + OncologySACTDosingMixin, + SACTDoseSummary, + sact_dose_evaluability, + summarize_sact_exposures, + summarize_sact_exposures_by, +) + +__all__ = [ + "OncologyCriticalWeightLossMixin", + "OncologyDrugExposure", + "OncologyEpisode", + "OncologyEpisodeEvent", + "OncologyEpisodeEventMixin", + "OncologyModality", + "OncologyProcedure", + "OncologyRTDosingMixin", + "OncologySACTDosingMixin", + "RTDoseSummary", + "SACTDoseSummary", + "cancer_indicating_surgery_parent_concept_ids", + "cancer_indicating_surgery_point_concept_ids", + "clear_concept_set_cache", + "diagnostic_staging_procedure_parent_concept_ids", + "diagnostic_staging_procedure_point_concept_ids", + "disease_episode_type_concept_ids", + "drug_concept_membership_expression", + "overarching_episode_type_concept_id", + "procedure_concept_membership_expression", + "resolve_cancer_indicating_surgery_procedure_concept_ids", + "resolve_diagnostic_staging_procedure_concept_ids", + "resolve_rt_procedure_concept_ids", + "resolve_sact_drug_concept_ids", + "rt_dose_evaluability", + "rt_procedure_parent_concept_ids", + "rt_site_key", + "sact_dose_evaluability", + "sact_drug_excluded_parent_concept_ids", + "sact_drug_parent_concept_ids", + "summarize_rt_procedures", + "summarize_rt_procedures_by", + "summarize_sact_exposures", + "summarize_sact_exposures_by", + "treatment_cycle_episode_concept_id", + "treatment_episode_type_concept_ids", + "treatment_regimen_episode_concept_id", +] diff --git a/omop_alchemy/toolkit/analytics/oncology/concept_sets.py b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py new file mode 100644 index 0000000..bd71b4a --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable + +import sqlalchemy.orm as so +from sqlalchemy import select + +from omop_alchemy.toolkit.core._semantics import default_semantics_runtime +from omop_alchemy.cdm.model import Concept_Ancestor + + +def _runtime_group(value_set_name: str, semantic_unit_name: str, group_name: str): + runtime = default_semantics_runtime() + value_set = getattr(runtime, value_set_name) + semantic_unit = getattr(value_set, semantic_unit_name) + return semantic_unit.groups[group_name] + + +def rt_procedure_parent_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "cancer_procedures", + "cancer_procedure_types", + "cancer_procedure_types", + ) + mapper = group.mapper() + return ( + mapper["rt_externalbeam"], + mapper["rt_procedure"], + mapper["rt_brachytherapy"], + ) + + +def cancer_indicating_surgery_parent_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "cancer_procedures", + "cancer_indicating_surgery_parent_concepts", + "cancer_indicating_surgery_parent_concepts", + ) + return tuple(group.mapper().values()) + + +def cancer_indicating_surgery_point_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "cancer_procedures", + "cancer_indicating_surgery_point_concepts", + "cancer_indicating_surgery_point_concepts", + ) + return tuple(group.mapper().values()) + + +def diagnostic_staging_procedure_parent_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "cancer_procedures", + "diagnostic_staging_procedure_parent_concepts", + "diagnostic_staging_procedure_parent_concepts", + ) + return tuple(group.mapper().values()) + + +def diagnostic_staging_procedure_point_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "cancer_procedures", + "diagnostic_staging_procedure_point_concepts", + "diagnostic_staging_procedure_point_concepts", + ) + return tuple(group.mapper().values()) + + +def sact_drug_parent_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "sact", + "sact_drug_classification", + "sact_drug_classification", + ) + return tuple(group.ids) + + +def sact_drug_excluded_parent_concept_ids() -> tuple[int, ...]: + group = _runtime_group( + "sact", + "sact_drug_classification", + "sact_drug_classification", + ) + return tuple(group.excluded_ids) + + +def disease_episode_type_concept_ids() -> tuple[int, ...]: + return tuple(default_semantics_runtime().types.disease_episode_types.ids) + + +def overarching_episode_type_concept_id() -> int: + return default_semantics_runtime().types.disease_episode_types.episode_of_care + + +def treatment_episode_type_concept_ids() -> tuple[int, ...]: + return tuple(default_semantics_runtime().types.treatment_episode_types.ids) + + +def treatment_regimen_episode_concept_id() -> int: + return default_semantics_runtime().types.treatment_episode_types.treatment_regimen + + +def treatment_cycle_episode_concept_id() -> int: + return default_semantics_runtime().types.treatment_episode_types.treatment_cycle + + +def _descendant_concept_ids( + session: so.Session, + ancestor_concept_ids: Iterable[int], +) -> frozenset[int]: + stmt = select(Concept_Ancestor.descendant_concept_id).where( + Concept_Ancestor.ancestor_concept_id.in_(tuple(ancestor_concept_ids)) + ) + return frozenset(session.execute(stmt).scalars().all()) + + +def procedure_concept_membership_expression( + concept_id_column, + parent_ids: Iterable[int], + point_ids: Iterable[int] = (), +): + expr = concept_id_column.in_( + select(Concept_Ancestor.descendant_concept_id).where( + Concept_Ancestor.ancestor_concept_id.in_(tuple(parent_ids)) + ) + ) + point_ids = tuple(point_ids) + if point_ids: + expr = expr | concept_id_column.in_(point_ids) + return expr + + +def drug_concept_membership_expression( + concept_id_column, + include_parent_ids: Iterable[int], + exclude_parent_ids: Iterable[int] = (), +): + expr = concept_id_column.in_( + select(Concept_Ancestor.descendant_concept_id).where( + Concept_Ancestor.ancestor_concept_id.in_(tuple(include_parent_ids)) + ) + ) + exclude_parent_ids = tuple(exclude_parent_ids) + if exclude_parent_ids: + expr = expr & concept_id_column.not_in( + select(Concept_Ancestor.descendant_concept_id).where( + Concept_Ancestor.ancestor_concept_id.in_(exclude_parent_ids) + ) + ) + return expr + + +class _ConceptSetCache: + def __init__(self) -> None: + self._cache: dict[tuple[int, str], frozenset[int]] = {} + + def get( + self, + name: str, + session: so.Session, + builder: Callable[[so.Session], frozenset[int]], + ) -> frozenset[int]: + bind = session.get_bind() + key = (id(bind), name) + if key not in self._cache: + self._cache[key] = builder(session) + return self._cache[key] + + def clear(self) -> None: + self._cache.clear() + + +_cache = _ConceptSetCache() + + +def resolve_rt_procedure_concept_ids(session: so.Session) -> frozenset[int]: + return _cache.get( + "rt_procedure_concept_ids", + session, + lambda s: _descendant_concept_ids(s, rt_procedure_parent_concept_ids()), + ) + + +def resolve_cancer_indicating_surgery_procedure_concept_ids( + session: so.Session, +) -> frozenset[int]: + def _build(s: so.Session) -> frozenset[int]: + closure = _descendant_concept_ids( + s, + cancer_indicating_surgery_parent_concept_ids(), + ) + return closure | frozenset(cancer_indicating_surgery_point_concept_ids()) + + return _cache.get("cancer_indicating_surgery_procedure_concept_ids", session, _build) + + +def resolve_diagnostic_staging_procedure_concept_ids( + session: so.Session, +) -> frozenset[int]: + def _build(s: so.Session) -> frozenset[int]: + closure = _descendant_concept_ids( + s, + diagnostic_staging_procedure_parent_concept_ids(), + ) + return closure | frozenset(diagnostic_staging_procedure_point_concept_ids()) + + return _cache.get("diagnostic_staging_procedure_concept_ids", session, _build) + + +def resolve_sact_drug_concept_ids(session: so.Session) -> frozenset[int]: + def _build(s: so.Session) -> frozenset[int]: + included = _descendant_concept_ids(s, sact_drug_parent_concept_ids()) + excluded = _descendant_concept_ids(s, sact_drug_excluded_parent_concept_ids()) + return included - excluded + + return _cache.get("sact_drug_concept_ids", session, _build) + + +def clear_concept_set_cache() -> None: + _cache.clear() diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_critical_weight_loss.py b/omop_alchemy/toolkit/analytics/oncology/oncology_critical_weight_loss.py new file mode 100644 index 0000000..2c04121 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_critical_weight_loss.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Any + +from omop_alchemy.toolkit.analytics.adverse_events import ( + critical_weight_loss_grade, + ctcae_weight_loss_grade, + martin_weight_loss_grade, +) +from omop_alchemy.toolkit.analytics.body_metrics import WeightTrajectoryMixin + + +class OncologyCriticalWeightLossMixin(WeightTrajectoryMixin): + """ + Critical-weight-loss support for oncology episodes. + + Body measurements stay in ``body_metrics`` and grading policy stays in + ``adverse_events``. This mixin only composes those reusable pieces onto the + oncology episode surface. + """ + + @cached_property + def ctcae_weight_loss_grade(self) -> int | None: + return ctcae_weight_loss_grade( + self.pct_change_from_baseline().pct_change, + ) + + @cached_property + def martin_weight_loss_grade(self) -> int | None: + return martin_weight_loss_grade( + self.pct_change_from_baseline().pct_change, + self.baseline_bmi, + ) + + @cached_property + def critical_weight_loss_grade(self) -> int | None: + return critical_weight_loss_grade( + self.pct_change_from_baseline().pct_change, + self.baseline_bmi, + ) + + def critical_weight_loss_summary(self) -> dict[str, Any]: + summary = self.weight_trajectory_summary() + summary.update( + { + "ctcae_weight_loss_grade": self.ctcae_weight_loss_grade, + "martin_weight_loss_grade": self.martin_weight_loss_grade, + "critical_weight_loss_grade": self.critical_weight_loss_grade, + } + ) + return summary diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_drug_exposure.py b/omop_alchemy/toolkit/analytics/oncology/oncology_drug_exposure.py new file mode 100644 index 0000000..3a27293 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_drug_exposure.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import object_session + +from omop_alchemy.cdm.model.clinical.drug_exposure import Drug_ExposureView + +from .concept_sets import ( + drug_concept_membership_expression, + resolve_sact_drug_concept_ids, + sact_drug_excluded_parent_concept_ids, + sact_drug_parent_concept_ids, +) + + +class OncologyDrugExposure(Drug_ExposureView): + """ + Oncology-aware drug exposure view. + + SACT classification is governed by omop-semantics and intentionally kept + separate from generic drug episode summaries. + """ + + @hybrid_property + def is_sact(self) -> bool: + session = object_session(self) + if session is None: + return False + return self.drug_concept_id in resolve_sact_drug_concept_ids(session) + + @is_sact.inplace.expression + @classmethod + def _is_sact_expression(cls): + return drug_concept_membership_expression( + cls.drug_concept_id, + sact_drug_parent_concept_ids(), + sact_drug_excluded_parent_concept_ids(), + ) diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_episodes.py b/omop_alchemy/toolkit/analytics/oncology/oncology_episodes.py new file mode 100644 index 0000000..aa2f625 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_episodes.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from enum import StrEnum +from functools import cached_property +from typing import Self, cast + +import sqlalchemy.orm as so +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import object_session + +from omop_alchemy.cdm.model.structural import EpisodeView +from omop_alchemy.toolkit.episodes.handling import DrugEpisodeMixin + +from .concept_sets import ( + disease_episode_type_concept_ids, + overarching_episode_type_concept_id, + treatment_cycle_episode_concept_id, + treatment_episode_type_concept_ids, + treatment_regimen_episode_concept_id, +) +from .oncology_critical_weight_loss import OncologyCriticalWeightLossMixin +from .oncology_drug_exposure import OncologyDrugExposure +from .oncology_event import OncologyEpisodeEventMixin +from .oncology_procedure_occurrence import OncologyProcedure +from .oncology_rt_dosing import OncologyRTDosingMixin +from .oncology_sact_dosing import OncologySACTDosingMixin + + +class OncologyModality(StrEnum): + SACT = "sact" + RADIOTHERAPY = "radiotherapy" + SURGERY = "surgery" + DIAGNOSTIC_STAGING = "diagnostic_staging" + UNKNOWN = "unknown" + + +class OncologyEpisode( + OncologyCriticalWeightLossMixin, + OncologySACTDosingMixin, + OncologyRTDosingMixin, + DrugEpisodeMixin, + OncologyEpisodeEventMixin, + EpisodeView, +): + """ + Oncology-aware episode view. + + This composes generic episode hierarchy support, oncology-aware + ``Episode_Event`` resolution, body-metric adverse-event grading, and + treatment dose-summary interfaces. Modality classification exposes both + structural treatment evidence, such as linked drug exposures, and governed + concept evidence, such as SACT-classified drug concepts, so callers can + audit disagreements. + """ + + @declared_attr + @classmethod + def children(cls) -> so.Mapped[list["OncologyEpisode"]]: + episode_id = cls.__table__.c.episode_id + episode_parent_id = cls.__table__.c.episode_parent_id + return so.relationship( + cls.__name__, + primaryjoin=lambda: so.remote(episode_parent_id) == episode_id, + foreign_keys=lambda: [episode_parent_id], + remote_side=lambda: [episode_parent_id], + viewonly=True, + lazy="selectin", + uselist=True, + ) + + @hybrid_property + def is_disease_episode(self) -> bool: + return self.episode_concept_id in disease_episode_type_concept_ids() + + @is_disease_episode.inplace.expression + @classmethod + def _is_disease_episode_expression(cls): + return cls.episode_concept_id.in_(disease_episode_type_concept_ids()) + + @hybrid_property + def is_overarching(self) -> bool: + return self.episode_concept_id == overarching_episode_type_concept_id() + + @is_overarching.inplace.expression + @classmethod + def _is_overarching_expression(cls): + return cls.episode_concept_id == overarching_episode_type_concept_id() + + @hybrid_property + def is_treatment_episode(self) -> bool: + return self.episode_concept_id in treatment_episode_type_concept_ids() + + @is_treatment_episode.inplace.expression + @classmethod + def _is_treatment_episode_expression(cls): + return cls.episode_concept_id.in_(treatment_episode_type_concept_ids()) + + @hybrid_property + def is_treatment_regimen(self) -> bool: + return self.episode_concept_id == treatment_regimen_episode_concept_id() + + @is_treatment_regimen.inplace.expression + @classmethod + def _is_treatment_regimen_expression(cls): + return cls.episode_concept_id == treatment_regimen_episode_concept_id() + + @hybrid_property + def is_treatment_cycle(self) -> bool: + return self.episode_concept_id == treatment_cycle_episode_concept_id() + + @is_treatment_cycle.inplace.expression + @classmethod + def _is_treatment_cycle_expression(cls): + return cls.episode_concept_id == treatment_cycle_episode_concept_id() + + @property + def primary_episode(self) -> Self: + current = self + while not current.is_overarching and current.episode_parent_id is not None: + session = object_session(current) + if session is None: + break + parent = session.get(type(self), current.episode_parent_id) + if parent is None: + break + current = parent + return current + + @cached_property + def child_treatment_episodes(self) -> list[Self]: + return [ + child + for child in cast(list[Self], self.children) + if child.is_treatment_episode + ] + + def _linked_oncology_events(self, *, include_child_events: bool = True) -> list[object]: + resolved: list[object] = list(self.events) + if include_child_events: + for child in cast(list[Self], self.children): + resolved.extend(child.events) + return resolved + + @cached_property + def structural_modality(self) -> OncologyModality: + """ + Classify modality from linked event structure. + + Any linked drug exposure is treated as structural SACT evidence, while + radiotherapy, surgery, and diagnostic/staging require governed procedure + concept membership. + """ + has_drug_exposure = False + for event in self._linked_oncology_events(): + if isinstance(event, OncologyDrugExposure): + has_drug_exposure = True + continue + if not isinstance(event, OncologyProcedure): + continue + if event.is_radiotherapy: + return OncologyModality.RADIOTHERAPY + if event.is_surgery: + return OncologyModality.SURGERY + if event.is_diagnostic_staging: + return OncologyModality.DIAGNOSTIC_STAGING + return OncologyModality.SACT if has_drug_exposure else OncologyModality.UNKNOWN + + @cached_property + def concept_modality(self) -> OncologyModality: + """ + Classify modality from linked procedure/drug concept identity. + + This is intentionally distinct from ``structural_modality`` so SACT + disagreements remain visible. + """ + for event in self._linked_oncology_events(): + if isinstance(event, OncologyDrugExposure): + if event.is_sact: + return OncologyModality.SACT + elif isinstance(event, OncologyProcedure): + if event.is_radiotherapy: + return OncologyModality.RADIOTHERAPY + if event.is_surgery: + return OncologyModality.SURGERY + if event.is_diagnostic_staging: + return OncologyModality.DIAGNOSTIC_STAGING + return OncologyModality.UNKNOWN + + @cached_property + def child_treatment_episodes_by_modality( + self, + ) -> dict[OncologyModality, list[Self]]: + groups: dict[OncologyModality, list[Self]] = { + modality: [] + for modality in OncologyModality + } + for child in self.child_treatment_episodes: + groups[child.structural_modality].append(child) + return groups + + @cached_property + def child_treatment_episodes_by_concept_modality( + self, + ) -> dict[OncologyModality, list[Self]]: + groups: dict[OncologyModality, list[Self]] = { + modality: [] + for modality in OncologyModality + } + for child in self.child_treatment_episodes: + groups[child.concept_modality].append(child) + return groups diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_event.py b/omop_alchemy/toolkit/analytics/oncology/oncology_event.py new file mode 100644 index 0000000..b261983 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_event.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Any, cast + +import sqlalchemy.orm as so +from sqlalchemy.ext.declarative import declared_attr + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.toolkit.episodes.handling import ResolvedEpisodeEvent + +from .oncology_drug_exposure import OncologyDrugExposure +from .oncology_procedure_occurrence import OncologyProcedure + + +class OncologyEpisodeEvent(ResolvedEpisodeEvent): + """ + Episode-event view that prefers oncology-aware fact table views. + + Non-oncology targets continue to use the base resolver map from + ``ResolvedEpisodeEvent``. Procedure and drug targets resolve to oncology + subclasses so episode traversal can ask domain-specific questions such as + ``is_radiotherapy`` and ``is_sact`` — and, since this extends + ``ResolvedEpisodeEvent`` rather than the bare ``Episode_EventView``, + ``event_resolution_diagnostics`` is available on oncology events too. + """ + + @classmethod + def resolved_event_target_classes(cls) -> dict[int, type[Any]]: + base_resolver = getattr(ResolvedEpisodeEvent, "resolved_event_target_classes") + targets = dict(base_resolver()) + targets.update( + { + ModifierFieldConcepts.PROCEDURE_OCCURRENCE: OncologyProcedure, + ModifierFieldConcepts.DRUG_EXPOSURE: OncologyDrugExposure, + } + ) + return targets + + +class OncologyEpisodeEventMixin: + """ + Relationship override for episode classes that need oncology event targets. + """ + + @declared_attr + @classmethod + def episode_events(cls) -> so.Mapped[list["OncologyEpisodeEvent"]]: + owner_episode_id = cast(Any, cls).__table__.c.episode_id + event_episode_id = OncologyEpisodeEvent.__table__.c.episode_id + return so.relationship( + "OncologyEpisodeEvent", + primaryjoin=lambda: owner_episode_id == event_episode_id, + viewonly=True, + lazy="selectin", + ) diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_procedure_occurrence.py b/omop_alchemy/toolkit/analytics/oncology/oncology_procedure_occurrence.py new file mode 100644 index 0000000..d948374 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_procedure_occurrence.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import object_session + +from omop_alchemy.cdm.model.clinical.procedure_occurrence import ( + Procedure_OccurrenceView, +) + +from .concept_sets import ( + cancer_indicating_surgery_parent_concept_ids, + cancer_indicating_surgery_point_concept_ids, + diagnostic_staging_procedure_parent_concept_ids, + diagnostic_staging_procedure_point_concept_ids, + procedure_concept_membership_expression, + resolve_cancer_indicating_surgery_procedure_concept_ids, + resolve_diagnostic_staging_procedure_concept_ids, + resolve_rt_procedure_concept_ids, + rt_procedure_parent_concept_ids, +) + + +class OncologyProcedure(Procedure_OccurrenceView): + """ + Oncology-aware procedure occurrence view. + + This maps the standard ``procedure_occurrence`` table and adds governed + concept-set membership checks used by oncology episode classification. + """ + + @hybrid_property + def is_radiotherapy(self) -> bool: + session = object_session(self) + if session is None: + return False + return self.procedure_concept_id in resolve_rt_procedure_concept_ids(session) + + @is_radiotherapy.inplace.expression + @classmethod + def _is_radiotherapy_expression(cls): + return procedure_concept_membership_expression( + cls.procedure_concept_id, + rt_procedure_parent_concept_ids(), + ) + + @hybrid_property + def is_surgery(self) -> bool: + session = object_session(self) + if session is None: + return False + return ( + self.procedure_concept_id + in resolve_cancer_indicating_surgery_procedure_concept_ids(session) + ) + + @is_surgery.inplace.expression + @classmethod + def _is_surgery_expression(cls): + return procedure_concept_membership_expression( + cls.procedure_concept_id, + cancer_indicating_surgery_parent_concept_ids(), + cancer_indicating_surgery_point_concept_ids(), + ) + + @hybrid_property + def is_diagnostic_staging(self) -> bool: + session = object_session(self) + if session is None: + return False + return ( + self.procedure_concept_id + in resolve_diagnostic_staging_procedure_concept_ids(session) + ) + + @is_diagnostic_staging.inplace.expression + @classmethod + def _is_diagnostic_staging_expression(cls): + return procedure_concept_membership_expression( + cls.procedure_concept_id, + diagnostic_staging_procedure_parent_concept_ids(), + diagnostic_staging_procedure_point_concept_ids(), + ) diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py b/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py new file mode 100644 index 0000000..00f245b --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from functools import cached_property +from typing import Any, Callable, Hashable, Sequence + +from omop_alchemy.toolkit.episodes.handling import DoseEvaluability + +from .oncology_procedure_occurrence import OncologyProcedure + + +@dataclass(frozen=True) +class RTDoseSummary: + """ + Radiotherapy procedure summary for one caller-chosen site/group key. + + OMOP procedure rows do not provide one universal RT dose model. This summary + exposes dates, procedure concepts, modifiers, counts, and evaluability so a + site-specific RT policy can decide what is clinically meaningful. + """ + + group_key: object + n_procedures: int + first_date: date | None + last_date: date | None + total_quantity: int | None + procedure_concept_ids: frozenset[int] + modifier_concept_ids: frozenset[int] + evaluability: DoseEvaluability + + +def rt_dose_evaluability( + procedures: Sequence[OncologyProcedure], +) -> DoseEvaluability: + if not procedures: + return DoseEvaluability(False, "no_rt_procedures") + if any(procedure.quantity is None for procedure in procedures): + return DoseEvaluability(False, "missing_quantity") + return DoseEvaluability(True) + + +def summarize_rt_procedures( + procedures: Sequence[OncologyProcedure], + *, + group_key: object, +) -> RTDoseSummary: + dates = [ + procedure.procedure_date + for procedure in procedures + if procedure.procedure_date is not None + ] + quantities = [ + procedure.quantity + for procedure in procedures + if procedure.quantity is not None + ] + return RTDoseSummary( + group_key=group_key, + n_procedures=len(procedures), + first_date=min(dates) if dates else None, + last_date=max(dates) if dates else None, + total_quantity=sum(quantities) if quantities else None, + procedure_concept_ids=frozenset( + procedure.procedure_concept_id + for procedure in procedures + ), + modifier_concept_ids=frozenset( + procedure.modifier_concept_id + for procedure in procedures + if procedure.modifier_concept_id is not None + ), + evaluability=rt_dose_evaluability(procedures), + ) + + +def summarize_rt_procedures_by( + procedures: Sequence[OncologyProcedure], + key: Callable[[OncologyProcedure], Hashable], +) -> list[RTDoseSummary]: + grouped: dict[Hashable, list[OncologyProcedure]] = defaultdict(list) + for procedure in procedures: + grouped[key(procedure)].append(procedure) + return [ + summarize_rt_procedures(rows, group_key=group_key) + for group_key, rows in grouped.items() + ] + + +def rt_site_key(procedure: OncologyProcedure) -> object: + return procedure.modifier_concept_id or procedure.procedure_source_value or "unknown_site" + + +class OncologyRTDosingMixin: + """ + RT dose/site summary interface for oncology treatment episodes. + """ + + def _linked_oncology_events(self, *, include_child_events: bool = True) -> list[Any]: + raise NotImplementedError + + @cached_property + def rt_procedures(self) -> list[OncologyProcedure]: + procedures = [ + event + for event in self._linked_oncology_events() + if isinstance(event, OncologyProcedure) and event.is_radiotherapy + ] + procedures.sort(key=lambda procedure: procedure.procedure_date) + return procedures + + @cached_property + def rt_dose_summaries_by_site(self) -> list[RTDoseSummary]: + return summarize_rt_procedures_by(self.rt_procedures, rt_site_key) + + @cached_property + def rt_dose_summary(self) -> RTDoseSummary: + return summarize_rt_procedures( + self.rt_procedures, + group_key="all_rt", + ) diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_sact_dosing.py b/omop_alchemy/toolkit/analytics/oncology/oncology_sact_dosing.py new file mode 100644 index 0000000..0abd647 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_sact_dosing.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from functools import cached_property +from typing import Any, Callable, Hashable, Sequence + +from omop_alchemy.toolkit.episodes.handling import ( + DOSE_EVALUABLE, + DoseEvaluability, + DrugExposureSummary, + summarize_drug_exposures, +) + +from .oncology_drug_exposure import OncologyDrugExposure + + +@dataclass(frozen=True) +class SACTDoseSummary: + """ + SACT dose summary for one caller-chosen drug grouping. + + This is deliberately a summary interface, not a dose-reduction rule. It + preserves mixed/missing units as evaluability states for downstream SACT + policy to interpret. + """ + + exposure_summary: DrugExposureSummary + evaluability: DoseEvaluability + + @property + def group_key(self) -> object: + return self.exposure_summary.group_key + + @property + def n_exposures(self) -> int: + return self.exposure_summary.n_exposures + + @property + def first_start_date(self) -> date | None: + return self.exposure_summary.first_start_date + + @property + def last_start_date(self) -> date | None: + return self.exposure_summary.last_start_date + + @property + def total_quantity(self) -> float | None: + return self.exposure_summary.total_quantity + + @property + def dose_unit_source_values(self) -> frozenset[str]: + return self.exposure_summary.dose_unit_source_values + + @property + def drug_concept_ids(self) -> frozenset[int]: + return self.exposure_summary.drug_concept_ids + + +def sact_dose_evaluability( + exposures: Sequence[OncologyDrugExposure], +) -> DoseEvaluability: + if not exposures: + return DoseEvaluability(False, "no_sact_exposures") + if any(exposure.quantity is None for exposure in exposures): + return DoseEvaluability(False, "missing_quantity") + units = { + exposure.dose_unit_source_value + for exposure in exposures + if exposure.dose_unit_source_value + } + if len(units) > 1: + return DoseEvaluability(False, "mixed_dose_units") + return DOSE_EVALUABLE + + +def summarize_sact_exposures( + exposures: Sequence[OncologyDrugExposure], + *, + group_key: object, +) -> SACTDoseSummary: + return SACTDoseSummary( + exposure_summary=summarize_drug_exposures(exposures, group_key=group_key), + evaluability=sact_dose_evaluability(exposures), + ) + + +def summarize_sact_exposures_by( + exposures: Sequence[OncologyDrugExposure], + key: Callable[[OncologyDrugExposure], Hashable], +) -> list[SACTDoseSummary]: + grouped: dict[Hashable, list[OncologyDrugExposure]] = defaultdict(list) + for exposure in exposures: + grouped[key(exposure)].append(exposure) + return [ + summarize_sact_exposures(rows, group_key=group_key) + for group_key, rows in grouped.items() + ] + + +class OncologySACTDosingMixin: + """ + SACT dose-summary interface for oncology treatment episodes. + """ + + def _linked_oncology_events(self, *, include_child_events: bool = True) -> list[Any]: + raise NotImplementedError + + @cached_property + def sact_exposures(self) -> list[OncologyDrugExposure]: + exposures = [ + event + for event in self._linked_oncology_events() + if isinstance(event, OncologyDrugExposure) and event.is_sact + ] + exposures.sort(key=lambda exposure: exposure.drug_exposure_start_date) + return exposures + + @cached_property + def sact_dose_summaries_by_drug_concept(self) -> list[SACTDoseSummary]: + return summarize_sact_exposures_by( + self.sact_exposures, + lambda exposure: exposure.drug_concept_id, + ) + + @cached_property + def sact_dose_summary(self) -> SACTDoseSummary: + return summarize_sact_exposures( + self.sact_exposures, + group_key="all_sact", + ) diff --git a/omop_alchemy/toolkit/episodes/handling/__init__.py b/omop_alchemy/toolkit/episodes/handling/__init__.py index 9def358..3d4b5cf 100644 --- a/omop_alchemy/toolkit/episodes/handling/__init__.py +++ b/omop_alchemy/toolkit/episodes/handling/__init__.py @@ -37,16 +37,39 @@ class MyEpisode(DrugEpisodeMixin, EpisodeView): traversal. """ +from .dosing import DOSE_EVALUABLE, DoseEvaluability +from .drug_episode import DrugEpisodeMixin +from .event_windowing import ( + DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, + episode_attachment_window, +) +from .exposure_series import resolve_drug_exposure_series from .resolved_event import ( EpisodeEventResolutionDiagnostic, ResolutionDiagnosticKind, ResolvedEpisodeEvent, ResolvedEpisodeEventMixin, ) +from .summaries import ( + DrugExposureSummary, + summarize_drug_exposures, + summarize_drug_exposures_by, +) __all__ = [ + "DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS", + "DEFAULT_EPISODE_WINDOW_DAYS_PRIOR", + "DOSE_EVALUABLE", + "DoseEvaluability", + "DrugEpisodeMixin", + "DrugExposureSummary", "EpisodeEventResolutionDiagnostic", "ResolutionDiagnosticKind", "ResolvedEpisodeEvent", "ResolvedEpisodeEventMixin", + "episode_attachment_window", + "resolve_drug_exposure_series", + "summarize_drug_exposures", + "summarize_drug_exposures_by", ] diff --git a/omop_alchemy/toolkit/episodes/handling/dosing.py b/omop_alchemy/toolkit/episodes/handling/dosing.py new file mode 100644 index 0000000..faead17 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/dosing.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class DoseEvaluability: + """ + Whether a dose-like summary can be interpreted as a dose quantity. + + Drug exposure rows often carry source units and quantities without enough + normalization to compare across agents or regimens. Domain-specific dosing + modules should use this as a small shared vocabulary instead of silently + treating missing or mixed units as comparable. + """ + + evaluable: bool + reason: Optional[str] = None + + +DOSE_EVALUABLE = DoseEvaluability(evaluable=True) diff --git a/omop_alchemy/toolkit/episodes/handling/drug_episode.py b/omop_alchemy/toolkit/episodes/handling/drug_episode.py new file mode 100644 index 0000000..7632889 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/drug_episode.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Callable, ClassVar, Hashable, Sequence + +from omop_alchemy.cdm.model import Drug_Exposure + +from .exposure_series import resolve_drug_exposure_series +from .summaries import DrugExposureSummary, summarize_drug_exposures_by + + +class DrugEpisodeMixin: + """ + Episode mixin for generic linked-drug retrieval and summary. + + Domain-specific subclasses can supply concept filters, grouping keys, and + dose validity rules. This base layer only knows how to find and summarise + drug exposure rows. + """ + + _drug_concept_ids: ClassVar[Sequence[int] | None] = None + _include_window_drug_exposures: ClassVar[bool] = False + + @cached_property + def drug_exposures(self) -> list[Drug_Exposure]: + return resolve_drug_exposure_series( + self, + self._drug_concept_ids, + include_window=self._include_window_drug_exposures, + ) + + def drug_exposure_summaries_by( + self, + key: Callable[[Drug_Exposure], Hashable] | None = None, + ) -> list[DrugExposureSummary]: + key = key or (lambda exposure: exposure.drug_concept_id) + return summarize_drug_exposures_by(self.drug_exposures, key) diff --git a/omop_alchemy/toolkit/episodes/handling/event_windowing.py b/omop_alchemy/toolkit/episodes/handling/event_windowing.py new file mode 100644 index 0000000..d8d86f0 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/event_windowing.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import date, timedelta + +DEFAULT_EPISODE_WINDOW_DAYS_PRIOR = 90 +DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS = 365 + + +def episode_attachment_window( + episode, + *, + days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, + open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, +) -> tuple[date, date]: + """ + Date window used for episode-attributable facts admitted by date rather + than by an explicit ``Episode_Event`` link. + + Time-varying facts such as body measurements use a bounded + episode-relative window. Open episodes fall back to a finite post-start + window so an accidentally long-running episode does not absorb a + person's entire future history. + """ + start = episode.episode_start_date + end = episode.episode_end_date + + window_start = start - timedelta(days=days_prior) + open_end_bound = start + timedelta(days=open_end_fallback_days) + window_end = min(end, open_end_bound) if end is not None else open_end_bound + + return window_start, window_end diff --git a/omop_alchemy/toolkit/episodes/handling/exposure_series.py b/omop_alchemy/toolkit/episodes/handling/exposure_series.py new file mode 100644 index 0000000..a7354a6 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/exposure_series.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.orm import object_session + +from omop_alchemy.cdm.model import Drug_Exposure + + +def _episode_date_bounds(episode): + start = episode.episode_start_date + end = episode.episode_end_date or start + return start, end + + +def resolve_drug_exposure_series( + episode, + concept_ids: Optional[Sequence[int]] = None, + *, + include_window: bool = False, +) -> list[Drug_Exposure]: + """ + Resolve drug exposures linked to an episode. + + Explicit ``Episode_Event`` links are always honoured. A bounded episode-date + fallback can be enabled by callers that have a meaningful concept set and + want to recover unlinked rows. The default stays explicit-only to avoid + accidentally attributing unrelated same-person medications to a drug + episode. + """ + concept_id_set = set(concept_ids) if concept_ids is not None else None + seen_ids: set[int] = set() + exposures: list[Drug_Exposure] = [] + + for event in episode.events: + if not isinstance(event, Drug_Exposure): + continue + if concept_id_set is not None and event.drug_concept_id not in concept_id_set: + continue + exposures.append(event) + seen_ids.add(event.drug_exposure_id) + + session = object_session(episode) + if include_window and session is not None: + start, end = _episode_date_bounds(episode) + stmt = select(Drug_Exposure).where( + Drug_Exposure.person_id == episode.person_id, + Drug_Exposure.drug_exposure_start_date.between(start, end), + ) + if concept_id_set is not None: + stmt = stmt.where(Drug_Exposure.drug_concept_id.in_(concept_id_set)) + + for row in session.execute(stmt).scalars(): + if row.drug_exposure_id in seen_ids: + continue + exposures.append(row) + seen_ids.add(row.drug_exposure_id) + + exposures.sort(key=lambda e: e.drug_exposure_start_date) + return exposures diff --git a/omop_alchemy/toolkit/episodes/handling/summaries.py b/omop_alchemy/toolkit/episodes/handling/summaries.py new file mode 100644 index 0000000..7d2ff50 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/handling/summaries.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from typing import Callable, Hashable, Optional, Sequence + +from omop_alchemy.cdm.model import Drug_Exposure + + +@dataclass(frozen=True) +class DrugExposureSummary: + """ + Small generic summary of drug exposures grouped by a caller-chosen key. + + The grouping key can be a drug concept, ingredient concept, regimen member, + or any downstream classifier. This type deliberately does not encode SACT or + any other clinical programme-specific policy. + """ + + group_key: object + n_exposures: int + first_start_date: Optional[date] + last_start_date: Optional[date] + total_quantity: Optional[float] + dose_unit_source_values: frozenset[str] + drug_concept_ids: frozenset[int] + + +def summarize_drug_exposures( + exposures: Sequence[Drug_Exposure], + *, + group_key: object, +) -> DrugExposureSummary: + quantities = [e.quantity for e in exposures if e.quantity is not None] + dates = [ + e.drug_exposure_start_date + for e in exposures + if e.drug_exposure_start_date is not None + ] + dose_units = frozenset( + e.dose_unit_source_value + for e in exposures + if e.dose_unit_source_value + ) + drug_concept_ids = frozenset(e.drug_concept_id for e in exposures) + + return DrugExposureSummary( + group_key=group_key, + n_exposures=len(exposures), + first_start_date=min(dates) if dates else None, + last_start_date=max(dates) if dates else None, + total_quantity=sum(quantities) if quantities else None, + dose_unit_source_values=dose_units, + drug_concept_ids=drug_concept_ids, + ) + + +def summarize_drug_exposures_by( + exposures: Sequence[Drug_Exposure], + key: Callable[[Drug_Exposure], Hashable], +) -> list[DrugExposureSummary]: + grouped: dict[Hashable, list[Drug_Exposure]] = defaultdict(list) + for exposure in exposures: + grouped[key(exposure)].append(exposure) + return [ + summarize_drug_exposures(rows, group_key=group_key) + for group_key, rows in grouped.items() + ] diff --git a/tests/test_adverse_events.py b/tests/test_adverse_events.py new file mode 100644 index 0000000..76d4eca --- /dev/null +++ b/tests/test_adverse_events.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from omop_alchemy.toolkit.analytics.adverse_events import ( + CTCAEWeightLoss, + MartinWeightLoss, + critical_weight_loss_grade, + ctcae_weight_loss_grade, +) + + +def test_martin_weight_loss_reference_cells(): + assert MartinWeightLoss.grade(pct_change=-1, bmi=30) == 0 + assert MartinWeightLoss.grade(pct_change=-20, bmi=18) == 4 + + +def test_martin_bmi_category_boundaries(): + assert [ + MartinWeightLoss._bmi_category_index(bmi) + for bmi in (30, 26, 23, 21, 18) + ] == [0, 1, 2, 3, 4] + + +def test_martin_weight_loss_category_boundaries(): + assert [ + MartinWeightLoss._weight_loss_category_index(loss_pct) + for loss_pct in (0, 2, 4, 8, 12, 20) + ] == [0, 0, 1, 2, 3, 4] + + +def test_ctcae_weight_loss_grade_counts_crossed_thresholds(): + assert CTCAEWeightLoss.grade(-12) == 2 + assert ctcae_weight_loss_grade(-12) == 2 + + +def test_critical_weight_loss_grade_falls_back_to_ctcae_without_bmi(): + assert critical_weight_loss_grade(pct_change=-12, bmi=None) == 2 + + +def test_critical_weight_loss_grade_requires_percent_change(): + assert critical_weight_loss_grade(pct_change=None, bmi=30) is None diff --git a/tests/test_body_metrics.py b/tests/test_body_metrics.py new file mode 100644 index 0000000..2a0e021 --- /dev/null +++ b/tests/test_body_metrics.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from omop_alchemy.toolkit.analytics.body_metrics import ( + BodyMetricRules, + BodySizeMeasurementConcepts, + MeasurementReading, + WeightTrajectoryMixin, +) +from omop_alchemy.toolkit.core.units import BodySizeUnitConcepts, BodyUnitConversionRules + + +def _body_metric_rules() -> BodyMetricRules: + return BodyMetricRules( + measurements=BodySizeMeasurementConcepts(weight=1, height=2), + unit_rules=BodyUnitConversionRules( + units=BodySizeUnitConcepts(kg=10, lb=11, cm=12, inch=13, m2=14), + ), + ) + + +def test_body_metric_rules_normalize_weight_kg(): + rules = _body_metric_rules() + + assert rules.normalize_weight_kg(70, rules.units.kg) == 70 + assert rules.normalize_weight_kg(150, rules.units.lb) == pytest.approx(68.0388555) + assert rules.normalize_weight_kg(70, 999) is None + assert rules.normalize_weight_kg(None, rules.units.kg) is None + assert rules.normalize_weight_kg(70, None) is None + + +def test_body_metric_rules_normalize_height_cm(): + rules = _body_metric_rules() + + assert rules.normalize_height_cm(170, rules.units.cm) == 170 + assert rules.normalize_height_cm(67, rules.units.inch) == pytest.approx(170.18) + assert rules.normalize_height_cm(170, 999) is None + assert rules.normalize_height_cm(None, rules.units.cm) is None + assert rules.normalize_height_cm(170, None) is None + + +def test_body_metric_rules_bmi(): + assert BodyMetricRules.bmi(70, 1.7) == pytest.approx(24.22, abs=0.01) + assert BodyMetricRules.bmi(None, 1.7) is None + assert BodyMetricRules.bmi(70, None) is None + assert BodyMetricRules.bmi(0, 1.7) is None + + +def test_body_metric_rules_bsa_mosteller_m2(): + assert BodyMetricRules.bsa_mosteller_m2(70, 170) == pytest.approx(1.818, abs=0.001) + + +class WeightTrajectoryStub(WeightTrajectoryMixin): + def __init__(self, readings: list[MeasurementReading]) -> None: + self.episode_id = 1 + self.person_id = 1 + self._readings = readings + + @property + def weight_readings(self) -> list[MeasurementReading]: + return self._readings + + +def _weight_reading( + measurement_id: int, + value: float, + reading_date: date = date(2020, 1, 1), +) -> MeasurementReading: + return MeasurementReading( + measurement_id=measurement_id, + date=reading_date, + value=value, + unit_concept_id=10, + concept_id=1, + source="explicit", + ) + + +def test_weight_change_single_reading_is_not_evaluable(): + episode = WeightTrajectoryStub([_weight_reading(1, 100)]) + + change = episode.pct_change_from_baseline() + + assert not change.evaluable + assert change.pct_change is None + + +def test_weight_change_same_measurement_id_is_not_evaluable(): + baseline = _weight_reading(1, 100) + same_measurement = _weight_reading(1, 95, date(2020, 1, 5)) + episode = WeightTrajectoryStub([baseline]) + + change = episode.pct_change_from_baseline(as_of=same_measurement) + + assert not change.evaluable + assert change.pct_change is None + + +def test_weight_change_near_zero_change_remains_evaluable(): + episode = WeightTrajectoryStub( + [ + _weight_reading(1, 100, date(2020, 1, 1)), + _weight_reading(2, 100.01, date(2020, 1, 5)), + ] + ) + + change = episode.pct_change_from_baseline() + + assert change.evaluable + assert change.pct_change == pytest.approx(0.01) From ccd3c6ce2578492078cbaaa2ae6cd43381165e98 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 17:01:59 +1000 Subject: [PATCH 5/7] toolkit docs update --- docs/toolkit/analytics.md | 66 +++++++++++++++++++ docs/toolkit/core.md | 36 ++++++++++ docs/toolkit/episodes.md | 63 ++++++++++++++++++ docs/toolkit/index.md | 55 ++++++++++++++++ docs/toolkit/integrations.md | 14 ++++ mkdocs.yml | 7 ++ omop_alchemy/toolkit/core/_semantics.py | 4 +- omop_alchemy/toolkit/episodes/__init__.py | 9 ++- .../toolkit/episodes/derivation/__init__.py | 24 +++---- omop_alchemy/toolkit/integrations/__init__.py | 2 +- .../integrations/meds_standard/__init__.py | 39 +---------- 11 files changed, 261 insertions(+), 58 deletions(-) create mode 100644 docs/toolkit/analytics.md create mode 100644 docs/toolkit/core.md create mode 100644 docs/toolkit/episodes.md create mode 100644 docs/toolkit/index.md create mode 100644 docs/toolkit/integrations.md diff --git a/docs/toolkit/analytics.md b/docs/toolkit/analytics.md new file mode 100644 index 0000000..8caa461 --- /dev/null +++ b/docs/toolkit/analytics.md @@ -0,0 +1,66 @@ +# analytics + +Clinical-domain logic, one subpackage per domain. Where `core` and `episodes` are +deliberately domain-agnostic, the subpackages here encode what a value *means* in a +particular clinical context: which concepts constitute a treatment, what counts as a +significant change, how severity is graded. Each domain owns its own concept sets, kept +beside the code that uses them, and domains may depend on each other where the clinical +logic genuinely composes. + +## oncology + +Cancer treatment and disease episodes. `OncologyEpisode` is the main entry point — it +classifies itself from its episode concepts and exposes the facts it contains: + +```python +from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode + +episode = session.get(OncologyEpisode, episode_id) +episode.structural_modality # OncologyModality.SACT, .RADIOTHERAPY, .SURGERY, ... +episode.is_treatment_cycle +episode.rt_dose_summaries_by_site +episode.sact_dose_summary +``` + +Episode traversal resolves to oncology-aware fact classes — `OncologyDrugExposure` and +`OncologyProcedure` extend their CDM counterparts with questions such as `is_sact` and +`is_radiotherapy` — and `OncologyEpisodeEvent` carries resolution diagnostics the same +way `episodes.handling`'s `ResolvedEpisodeEvent` does, since it extends it. + +::: omop_alchemy.toolkit.analytics.oncology + +## body_metrics + +Weight, height, and BMI as measurement series and trajectories. `WeightTrajectoryMixin` +gives an episode view normalised weight and height, BMI, and windowed weight change: + +```python +from omop_alchemy.toolkit.analytics.body_metrics import WeightTrajectoryMixin + +class MyEpisode(WeightTrajectoryMixin, EpisodeView): + ... + +episode.baseline_bmi +episode.pct_change_over(days=180) +``` + +`WeightChange.pct_change` is `None` whenever the change is not evaluable — too few +readings, or an unusable unit — so callers cannot mistake an unknown for a zero. + +::: omop_alchemy.toolkit.analytics.body_metrics + +## adverse_events + +Grades clinical severity against published criteria, kept separate from the +measurement code in `body_metrics` so the standard being applied is always explicit. + +```python +from omop_alchemy.toolkit.analytics.adverse_events import critical_weight_loss_grade + +grade = critical_weight_loss_grade(pct_change=-12.0, bmi=21.4) +``` + +`critical_weight_loss_grade` uses the Martin et al. BMI-adjusted matrix where BMI is +available and falls back to CTCAE-style percent-loss bins where it is not. + +::: omop_alchemy.toolkit.analytics.adverse_events diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md new file mode 100644 index 0000000..0fa5886 --- /dev/null +++ b/docs/toolkit/core.md @@ -0,0 +1,36 @@ +# core + +Foundational services with no clinical-domain assumptions. A concept resolver behaves +the same whether it is mapping tumour morphology or procedures; a patient timeline is +the same object whatever populates it. Domain-specific concept sets, thresholds, and +grading rules belong in [`analytics`](analytics.md), not here. + +## Concept resolution + +Turns a declarative description of *which* concepts belong in a lookup into a runtime +resolver that maps free text and source codes to OMOP concept IDs. + +```python +from omop_alchemy.toolkit.core.concepts import LookupSpec, make_concept_resolver + +resolver = make_concept_resolver(session, spec) +concept_id = resolver.resolve("Adenocarcinoma of lung") +``` + +::: omop_alchemy.toolkit.core.concepts + +## Patient timelines + +Projects a person's clinical rows — conditions, measurements, drug exposures — into a +single time-ordered event stream. This has its own dedicated page, since it predates the +rest of the toolkit reorg: see [Patient Timelines](../advanced/timelines.md). + +## Unit conversion + +Converts measurement values to canonical units. Kilograms, pounds, centimetres, and +inches mean the same thing in every clinical domain, so the conversion rules live here +rather than with any one domain's measurement logic — see +[`analytics.body_metrics`](analytics.md#body_metrics) for where those domain-specific +measurements are resolved and normalised using these rules. + +::: omop_alchemy.toolkit.core.units diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md new file mode 100644 index 0000000..2b586f5 --- /dev/null +++ b/docs/toolkit/episodes.md @@ -0,0 +1,63 @@ +# episodes + +Domain-neutral machinery for building episodes and retrieving what belongs to them. A +drug episode behaves the same whether the drug is a cytotoxic agent or an antibiotic, so +everything here takes concept filters and grouping keys as parameters rather than +assuming a clinical specialty. Domain-specific episode classes — for example +`OncologyEpisode` — compose these pieces with their own concept sets and live in +[`analytics`](analytics.md). + +## derivation + +How episodes are constructed and related to one another — building episode queries and +resolving parent/child hierarchy, written against the raw `Episode`/`Episode_Event` +tables rather than any materialised view. + +Not yet populated. The equivalent built against materialised-view subclasses lives in +`omop-constructs`. + +## handling + +What is inside an episode once it exists. + +**Linked drug exposures.** `DrugEpisodeMixin` adds retrieval and grouped summaries to +any episode view: + +```python +from omop_alchemy.toolkit.episodes.handling import DrugEpisodeMixin + +class MyEpisode(DrugEpisodeMixin, EpisodeView): + _drug_concept_ids = my_concept_ids + +episode.drug_exposures # resolved Drug_Exposure rows +episode.drug_exposure_summaries_by() # grouped by drug concept by default +``` + +Dose quantities are frequently not comparable across agents, because source units and +quantities arrive unnormalised. `DoseEvaluability` carries that judgement alongside the +number, so a summary that cannot be interpreted as a dose says so rather than presenting +a misleading total. + +**Explicit links versus admitted-by-window.** Facts linked through `Episode_Event` are +always honoured. `episode_attachment_window` computes the bounded, date-based fallback +window used when a caller opts in to admitting same-person facts that weren't explicitly +linked. + +**Resolution diagnostics.** `Episode_EventView.resolved_event` already resolves an +`Episode_Event` link best-effort, returning `None` on failure. `ResolvedEpisodeEvent` +extends it to explain *why* — a miscoded field concept, a target class not yet +registered, or a genuinely dangling reference: + +```python +from omop_alchemy.toolkit.episodes.handling import ResolvedEpisodeEvent + +ee = session.get(ResolvedEpisodeEvent, (episode_id, event_id, field_concept_id)) +ee.resolved_event # the resolved row, or None +ee.event_resolution_diagnostics # [] if resolved cleanly, otherwise why not +``` + +Mix `ResolvedEpisodeEventMixin` into an episode view to reach diagnostics through +ordinary `episode.episode_events` traversal instead of a direct query — this is how +`OncologyEpisode` gets diagnostics on oncology-aware event resolution for free. + +::: omop_alchemy.toolkit.episodes.handling diff --git a/docs/toolkit/index.md b/docs/toolkit/index.md new file mode 100644 index 0000000..145568c --- /dev/null +++ b/docs/toolkit/index.md @@ -0,0 +1,55 @@ +# Toolkit + +`omop_alchemy.cdm` gives you the OMOP CDM schema as SQLAlchemy models. The toolkit is +what you build with them: vocabulary resolution, patient timelines, episode traversal, +domain analytics, and outbound export. + +!!! warning "Experimental" + The toolkit is newer and less battle-tested than the CDM models it sits on top of. + Module paths below an area (anything past `toolkit..`) may still move; + the area itself is the stable import surface. The CDM models themselves are not + affected by anything here. + +## Four tiers + +Each tier may depend only on the tiers before it in this list — `core` knows nothing of +episodes or clinical domains, and nothing depends on `integrations`. + +| Tier | Answers | Assumes a clinical domain? | +|---|---|---| +| [`core`](core.md) | Resolve concepts, build a patient timeline, convert units | No | +| [`episodes`](episodes.md) | Build episodes, retrieve what belongs to one | No | +| [`analytics`](analytics.md) | What does this value mean clinically? | Yes, one subpackage per domain | +| [`integrations`](integrations.md) | Export to an external data standard | No | + +## A worked example + +Querying an oncology episode pulls together all four tiers without you having to think +about the seams between them: concept resolution and timelines from `core`, drug +retrieval from `episodes`, oncology classification from `analytics`. + +```python +from sqlalchemy.orm import Session +from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode + +with Session(engine) as session: + episode = session.get(OncologyEpisode, episode_id) + + episode.structural_modality # OncologyModality.SACT, .RADIOTHERAPY, ... + episode.drug_exposures # linked Drug_Exposure rows + episode.rt_dose_summary # RTDoseSummary, if any RT was given + episode.critical_weight_loss_grade # graded against Martin/CTCAE criteria +``` + +## Import surface + +Import from the area subpackage — `omop_alchemy.toolkit..` — not from a +specific module beneath it: + +```python +from omop_alchemy.toolkit.core.concepts import make_concept_resolver +from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode +``` + +Each area's `__init__.py` re-exports its public names, and that's the part of the path +that stays stable. Files beneath it are free to move. diff --git a/docs/toolkit/integrations.md b/docs/toolkit/integrations.md new file mode 100644 index 0000000..84605d2 --- /dev/null +++ b/docs/toolkit/integrations.md @@ -0,0 +1,14 @@ +# integrations + +Export to external data standards. Integrations sit at the outer edge of the toolkit — +one may use anything in `core`, `episodes`, or `analytics`, and nothing in those tiers +depends on an integration, so adding or changing an export format cannot affect the +clinical logic beneath it. Each integration brings its own heavyweight dependencies and +is gated behind an optional extra, so installing omop-alchemy does not pull in formats +you are not exporting to. + +## meds_standard + +Export to the [Medical Event Data Standard](https://github.com/Medical-Event-Data-Standard/meds). + +Not yet populated. diff --git a/mkdocs.yml b/mkdocs.yml index ca89aba..cebc5db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,13 @@ nav: - Domain: models/vocabulary/domain.md - Vocabulary: models/vocabulary/vocabulary.md + - Toolkit: + - Overview: toolkit/index.md + - core: toolkit/core.md + - episodes: toolkit/episodes.md + - analytics: toolkit/analytics.md + - integrations: toolkit/integrations.md + - OMOP-Specific Validation: - Overview: validation/index.md - Domain Rules: validation/domain-rules.md diff --git a/omop_alchemy/toolkit/core/_semantics.py b/omop_alchemy/toolkit/core/_semantics.py index 276085b..76120ae 100644 --- a/omop_alchemy/toolkit/core/_semantics.py +++ b/omop_alchemy/toolkit/core/_semantics.py @@ -7,7 +7,7 @@ def default_semantics_runtime() -> Any: """ Return the bundled omop-semantics runtime, or fail with install guidance. - omop-semantics is an optional dependency of omop-alchemy. Handler modules + omop-semantics is an optional dependency of omop-alchemy. Toolkit modules that use governed default concept sets should call this lazily when those defaults are actually needed, so importing core CDM models does not require the semantics package. @@ -16,7 +16,7 @@ def default_semantics_runtime() -> Any: from omop_semantics.runtime.default_valuesets import runtime except ImportError as exc: raise ImportError( - "This handler requires omop-semantics. Install omop-alchemy with " + "This feature requires omop-semantics. Install omop-alchemy with " "the 'semantics' extra, for example: omop-alchemy[semantics]." ) from exc return runtime diff --git a/omop_alchemy/toolkit/episodes/__init__.py b/omop_alchemy/toolkit/episodes/__init__.py index cd601fd..777b3a0 100644 --- a/omop_alchemy/toolkit/episodes/__init__.py +++ b/omop_alchemy/toolkit/episodes/__init__.py @@ -6,12 +6,15 @@ ``derivation`` How episodes are constructed and related to one another — building - episode queries, resolving parent/child hierarchy, and establishing - the windows over which events are attributed. + episode queries and resolving parent/child hierarchy. Not yet + populated; the equivalent built against materialised views lives in + ``omop-constructs``. ``handling`` What is inside an episode once it exists — retrieving the linked - drug exposures, procedures, and measurements, and summarising them. + drug exposures, procedures, and measurements, admitting same-person + facts within a bounded window when an explicit link is unavailable, + and summarising what was found. Everything here is domain-neutral. A drug episode behaves the same whether the drug is a cytotoxic agent or an antibiotic, so the mixins and diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index 8b2c36b..8ae225c 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -3,19 +3,13 @@ Episodes in the CDM are rows that reference each other through ``episode_parent_id`` and reach clinical facts through ``Episode_Event``. Turning that into something queryable — a regimen with its cycles, a -diagnosis with the treatment that followed — is what this module does. - -It provides queries that select episodes by concept, that join parent and -child episodes into a single result so a hierarchy can be read in one -pass, and that establish the date windows relating one episode to -another. - -These queries are written against the raw ``Episode`` and -``Episode_Event`` tables and depend on no materialised views, so they run -against any conformant CDM instance. Query construction against -materialised-view subclasses lives in ``omop-constructs``. - -Callers supply the episode concept IDs they care about. Derivation -determines structure; it does not decide which concepts constitute a -regimen or a disease course for a given specialty. +diagnosis with the treatment that followed — is this tier's job: queries +that select episodes by concept, join parent and child episodes into a +single result so a hierarchy can be read in one pass, and establish the +date windows relating one episode to another, written against the raw +``Episode``/``Episode_Event`` tables rather than any materialised view. + +Not yet populated in this package. The equivalent built against +materialised-view subclasses lives in ``omop-constructs``; nothing has +moved into ``toolkit`` yet. """ diff --git a/omop_alchemy/toolkit/integrations/__init__.py b/omop_alchemy/toolkit/integrations/__init__.py index 03c8ead..77fc98b 100644 --- a/omop_alchemy/toolkit/integrations/__init__.py +++ b/omop_alchemy/toolkit/integrations/__init__.py @@ -7,7 +7,7 @@ format therefore cannot affect the clinical logic beneath it. ``meds_standard`` - Export to the Medical Event Data Standard. + Export to the Medical Event Data Standard. Not yet populated. Integrations bring their own heavyweight dependencies and each is gated behind an optional extra, so installing omop-alchemy does not pull in diff --git a/omop_alchemy/toolkit/integrations/meds_standard/__init__.py b/omop_alchemy/toolkit/integrations/meds_standard/__init__.py index b173ff8..0f16326 100644 --- a/omop_alchemy/toolkit/integrations/meds_standard/__init__.py +++ b/omop_alchemy/toolkit/integrations/meds_standard/__init__.py @@ -1,44 +1,9 @@ """Export an OMOP CDM database to a MEDS-compliant dataset. The `Medical Event Data Standard`_ represents a patient's record as a flat -sequence of coded events. This module converts a CDM instance into that -form, serialising to sharded Parquet with PyArrow and emitting the code -metadata that makes the export self-describing. +sequence of coded events, serialised to sharded Parquet. -Requires the optional extras:: - - pip install omop_alchemy[meds] - -Whole-dataset export:: - - from omop_alchemy.toolkit.integrations.meds_standard import MEDSWriter - - with Session() as session: - result = MEDSWriter(session, "/path/to/output").write() - - print(f"Exported {result.subjects_exported} subjects, " - f"{result.events_emitted} events across {result.shards_written} shards.") - -``WriteResult`` also reports ``drop_counts`` per source table. Rows that -cannot be represented as MEDS events are dropped and counted rather than -silently omitted, so an export can be reconciled against its source. - -Single-patient export, for inspection or interactive work:: - - from omop_alchemy.toolkit.integrations.meds_standard import ( - Person_MEDS, - build_concept_id_map, - ) - - code_map, _ = build_concept_id_map(session) - person = session.get(Person_MEDS, 12345) - table = person.to_meds_table(code_map) # pa.Table, validates DataSchema - -Conditions, drug exposures, measurements, observations, procedures, and -device exposures each have an event class that maps their CDM columns onto -the MEDS event schema. Source concepts with no standard mapping are -assigned custom codes numbered from ``CUSTOM_CONCEPT_ID_START``, keeping -them distinguishable from OMOP concept IDs in the exported data. +Not yet populated in this toolkit. .. _Medical Event Data Standard: https://github.com/Medical-Event-Data-Standard/meds """ From 3f257c24817cc7a43c38a564b062897c0d7e7b7b Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 17:11:27 +1000 Subject: [PATCH 6/7] added import linter into workflow --- .github/workflows/ci.yml | 9 +++++++++ .gitignore | 3 ++- docs/toolkit/core.md | 12 ++++++++---- omop_alchemy/cdm/base/modifier_interface.py | 3 +-- omop_alchemy/cdm/model/structural/episode_event.py | 2 +- .../toolkit/analytics/body_metrics/__init__.py | 4 ++-- omop_alchemy/toolkit/core/concepts/__init__.py | 8 ++++++-- omop_alchemy/toolkit/core/timeline/__init__.py | 2 +- 8 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5261635..1afe869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,15 @@ on: jobs: label-gate: uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main + lint-imports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v5 + - run: uv run --frozen --extra dev lint-imports build-test-sqlite: uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main with: diff --git a/.gitignore b/.gitignore index 1002bd7..3e87184 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,5 @@ temp/ notebooks/ .dockerignore docker/ -tests/test_meds* \ No newline at end of file +tests/test_meds* +site/ \ No newline at end of file diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 0fa5886..7df27de 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -11,10 +11,14 @@ Turns a declarative description of *which* concepts belong in a lookup into a ru resolver that maps free text and source codes to OMOP concept IDs. ```python -from omop_alchemy.toolkit.core.concepts import LookupSpec, make_concept_resolver - -resolver = make_concept_resolver(session, spec) -concept_id = resolver.resolve("Adenocarcinoma of lung") +from omop_alchemy.toolkit.core.concepts import make_concept_resolver + +resolver = make_concept_resolver( + session, + name="condition lookup", + domain_id="Condition", +) +concept_id = resolver.lookup("Adenocarcinoma of lung") ``` ::: omop_alchemy.toolkit.core.concepts diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index 87f8c8a..a21f3e0 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -50,5 +50,4 @@ def end_date(self) -> Optional[date]: @property def type_concept_id(self) -> int: - return getattr(self, self.__type_concept_id_col__) - + return getattr(self, self.__type_concept_id_col__) \ No newline at end of file diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index db6edab..3a51204 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -160,4 +160,4 @@ def resolved_event_id_column(self) -> str | None: """ if self.event_field and "." in self.event_field.concept_name: return self.event_field.concept_name.split(".", 1)[1] - return None + return None \ No newline at end of file diff --git a/omop_alchemy/toolkit/analytics/body_metrics/__init__.py b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py index 80fe1db..83ae3e0 100644 --- a/omop_alchemy/toolkit/analytics/body_metrics/__init__.py +++ b/omop_alchemy/toolkit/analytics/body_metrics/__init__.py @@ -27,8 +27,8 @@ class MyEpisode(WeightTrajectoryMixin, EpisodeView): ... - episode.bmi - episode.weight_change(days=180) + episode.baseline_bmi + episode.pct_change_over(days=180) ``WeightChange`` reports ``pct_change`` as ``None`` whenever the change is not evaluable — too few readings, or an unusable unit — so callers cannot diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py index 79deb04..56064c9 100644 --- a/omop_alchemy/toolkit/core/concepts/__init__.py +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -24,8 +24,12 @@ from omop_alchemy.toolkit.core.concepts import make_concept_resolver - resolver = make_concept_resolver(session, spec) - concept_id = resolver.resolve("Adenocarcinoma of lung") + resolver = make_concept_resolver( + session, + name="condition lookup", + domain_id="Condition", + ) + concept_id = resolver.lookup("Adenocarcinoma of lung") Normalisation is composable. ``compose_normalizers`` chains individual rules — ``normalize_default`` for whitespace and casing, ``strip_uicc`` diff --git a/omop_alchemy/toolkit/core/timeline/__init__.py b/omop_alchemy/toolkit/core/timeline/__init__.py index 0a230f3..7cb044e 100644 --- a/omop_alchemy/toolkit/core/timeline/__init__.py +++ b/omop_alchemy/toolkit/core/timeline/__init__.py @@ -13,7 +13,7 @@ from omop_alchemy.toolkit.core.timeline import Person_Timeline for event in person.timeline: - print(event.time, event.concept_id, event.value) + print(event.event_time.start, event.concept_id, event.event_value().value) The timeline is a flat chronological view. It carries no notion of which events group together into a treatment course or disease phase — that From 22660a81ed53eaaf06ffcce7c5d82a9287e1ac40 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 7 Aug 2026 17:19:20 +1000 Subject: [PATCH 7/7] code review updates --- omop_alchemy/cdm/base/__init__.py | 3 ++- .../cdm/model/structural/episode_event.py | 21 ++++++++++++++++++- .../body_metrics/weight_trajectory.py | 11 ++++++++-- .../analytics/oncology/oncology_rt_dosing.py | 6 +++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/omop_alchemy/cdm/base/__init__.py b/omop_alchemy/cdm/base/__init__.py index 246c737..69ef248 100644 --- a/omop_alchemy/cdm/base/__init__.py +++ b/omop_alchemy/cdm/base/__init__.py @@ -1,5 +1,5 @@ from .cdm_table_base import CDMTableBase -from .decorators import cdm_table +from .decorators import cdm_table, MODEL_MODULE_PREFIX from .column_helpers import required_concept_fk, optional_concept_fk, optional_int, required_int from .column_mixins import ValueMixin, ReferenceTable, DatedEvent, PersonScoped, HealthSystemContext, FactTable from .indexing import merge_table_args, omop_index, omop_primary_key_index_name, omop_table_options @@ -14,6 +14,7 @@ "ExpectedDomain", "CDMTableBase", "cdm_table", + "MODEL_MODULE_PREFIX", "required_concept_fk", "optional_concept_fk", "optional_int", diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index 3a51204..941aa03 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -6,6 +6,7 @@ from omop_alchemy.cdm.base import ( cdm_table, CDMTableBase, + MODEL_MODULE_PREFIX, ReferenceContext, DomainValidationMixin, ExpectedDomain, @@ -20,11 +21,29 @@ def _modifier_target_classes_by_field_concept_id() -> dict[int, Type[Any]]: + """ + Default field-concept -> ORM target class map, CDM classes only. + + Scans every registered mapper, so iteration order is otherwise at the + mercy of import order. Sorting makes the result deterministic, and + restricting to ``cdm.model`` (the same ``MODEL_MODULE_PREFIX`` that + ``@cdm_table`` itself classifies tables by) excludes toolkit/domain + subclasses (e.g. oncology-aware views) that also implement + ``ModifierTargetMixin`` -- those are reached only through an explicit + override such as ``OncologyEpisodeEvent.resolved_event_target_classes``, + never by accident here. + """ classes: dict[int, Type[Any]] = {} - for mapper in Base.registry.mappers: + mappers = sorted( + Base.registry.mappers, + key=lambda mapper: f"{mapper.class_.__module__}.{mapper.class_.__qualname__}", + ) + for mapper in mappers: cls = mapper.class_ if not issubclass(cls, ModifierTargetMixin): continue + if not cls.__module__.startswith(MODEL_MODULE_PREFIX): + continue try: field_concept_id = cls.modifier_field_concept_id() except NotImplementedError: diff --git a/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py index ad08d7a..481badb 100644 --- a/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py +++ b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py @@ -153,6 +153,7 @@ def pct_change_from_baseline( or target is None or baseline.measurement_id == target.measurement_id or baseline.value is None + or baseline.value <= 0 or target.value is None ): return WeightChange.not_evaluable() @@ -170,6 +171,7 @@ def pct_change_over(self, days: int) -> WeightChange: if ( latest.value is None or earliest_in_window.value is None + or earliest_in_window.value <= 0 or earliest_in_window.measurement_id == latest.measurement_id ): return WeightChange.not_evaluable() @@ -178,7 +180,7 @@ def pct_change_over(self, days: int) -> WeightChange: def pct_change_trajectory(self) -> list[WeightTrajectoryPoint]: baseline = self.baseline_weight - if baseline is None or baseline.value is None: + if baseline is None or baseline.value is None or baseline.value <= 0: return [] points: list[WeightTrajectoryPoint] = [] for reading in self.weight_readings: @@ -203,7 +205,12 @@ def sustained_loss( ) -> Optional[bool]: baseline = self.baseline_weight readings = self.weight_readings - if baseline is None or baseline.value is None or len(readings) < min_consecutive: + if ( + baseline is None + or baseline.value is None + or baseline.value <= 0 + or len(readings) < min_consecutive + ): return None tail = readings[-min_consecutive:] if any(r.value is None for r in tail): diff --git a/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py b/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py index 00f245b..936975e 100644 --- a/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py +++ b/omop_alchemy/toolkit/analytics/oncology/oncology_rt_dosing.py @@ -89,7 +89,11 @@ def summarize_rt_procedures_by( def rt_site_key(procedure: OncologyProcedure) -> object: - return procedure.modifier_concept_id or procedure.procedure_source_value or "unknown_site" + if procedure.modifier_concept_id is not None: + return procedure.modifier_concept_id + if procedure.procedure_source_value is not None: + return procedure.procedure_source_value + return "unknown_site" class OncologyRTDosingMixin: